Add runtime event scope processing

This commit is contained in:
trifonovt 2026-05-25 22:42:11 +02:00
parent 3bccda20e8
commit b04b333db7
44 changed files with 4927 additions and 207 deletions

View File

@ -0,0 +1,103 @@
# Runtime-derived tachograph projections
Runtime Processing now exposes the tachograph driving-derived Esper bundle through the unified runtime event assembly layer.
## Endpoint
```http
POST /api/eventhub/runtime-processing/driver-derived-projections
```
The request body uses the same selector fields as the existing runtime endpoints:
- `sessionId` for one uploaded tachograph file session
- `sessionIds` for multiple uploaded tachograph file sessions
- `compositeSessionId` for an existing tachograph composite session
- `tenantKey` + driver selector for tachograph DB / YellowFox DB runtime sources
- `eventBackend` with `SOURCE_DB` or `EVENTHUB_DB` where supported
- `sourceFamilies`, for example `TACHOGRAPH_FILE_SESSION`, `TACHOGRAPH_DB`, `YELLOWFOX_DB`
- `driverKey`, `driverSourceEntityId`, `driverCardNation`, `driverCardNumber`
- `occurredFrom`, `occurredTo`
- `expandVehicleEvents`
- `vehicleExpansionPaddingMinutes`
Additional Esper thresholds are optional:
- `significantDrivingMinutes`
- `minimumRestPeriodMinutes`
When omitted, the defaults are read from:
```yaml
eventhub:
tachograph-file-session:
processing:
significant-driving-minutes: 3
minimum-rest-period-minutes: 720
```
## Flow
```text
runtime request
-> UnifiedRuntimeEventAssemblyService
-> driver seed events
-> discovered vehicles
-> optional vehicle-expanded events
-> merged runtime event stream
-> UnifiedEventTimelineReconstructor
-> DriverTimelineReusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundleFromEvents(...)
-> TachographEsperDriverProcessingResultDto
```
The derived part always uses the event-input Esper path. This means the runtime stream is passed as point events to Esper, where activity and card-vehicle-usage intervals are paired and vehicle-usage intervals are merged before the existing driving-derived rules run.
## Example: composite tachograph file session
```json
{
"compositeSessionId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"sourceFamilies": ["TACHOGRAPH_FILE_SESSION"],
"driverKey": "12:12345678901234",
"occurredFrom": "2026-05-01T00:00:00Z",
"occurredTo": "2026-05-31T23:59:59Z",
"expandVehicleEvents": true,
"vehicleExpansionPaddingMinutes": 15,
"significantDrivingMinutes": 3,
"minimumRestPeriodMinutes": 720
}
```
## Response
The response contains runtime assembly metadata and the tachograph Esper result:
```text
request
driverSeedEventCount
discoveredVehicleCount
expandedVehicleEventCount
mergedEventCount
discoveredVehicles
projection
notes
```
`projection` is the same high-level structure returned by the tachograph file-session Esper endpoint, including:
- activity interval count/list
- driving interval count/list
- driving interruption intervals
- daily/weekly rest candidate intervals
- daily/weekly rest candidate coverage intervals
- unclassified rest candidate coverage intervals
- potential home overnight stays
- potential in-vehicle overnight stays
- potential in-vehicle trips
- vehicle usage intervals
- VU card absent intervals
- support geo events
## Boundary note
Runtime processing works with point events. If `occurredFrom`/`occurredTo` cuts through a source interval, the matching START/END or INSERT/WITHDRAW point may be outside the selected window. For evaluations near boundaries, request a wider event window or use vehicle expansion padding so Esper receives enough point events to reconstruct the interval.

View File

@ -0,0 +1,246 @@
# Runtime Event Processing
Runtime Processing is now source-neutral. The API receives a runtime scope, selects a processing profile, partitions the normalized EventHub-style events, and delegates execution to the selected profile.
The tachograph Esper processing is no longer the root concept. It is one profile:
```text
tachograph-driver-esper-v1
```
## Profile discovery endpoint
```http
GET /api/eventhub/runtime-processing/event-processing/profiles
```
Example response:
```json
[
{
"profileKey": "tachograph-driver-esper-v1",
"displayName": "Tachograph Driver Esper Processing",
"description": "Runs the shared tachograph driver Esper processing pipeline over Runtime Processing event scopes.",
"defaultPartitioningStrategy": "DRIVER",
"supportedPartitioningStrategies": ["DRIVER"],
"requiredParameters": [],
"optionalParameters": [
"significantDrivingMinutes",
"minimumRestPeriodMinutes",
"attachVehicleOnlyEvents",
"vehicleEvidencePaddingMinutes"
]
}
]
```
Clients should prefer this endpoint instead of hardcoding profile metadata.
## Generic execution endpoint
```http
POST /api/eventhub/runtime-processing/event-processing
```
## Request shape
```json
{
"profileKey": "tachograph-driver-esper-v1",
"scope": {
"sessionIds": [
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222"
],
"sourceFamilies": ["TACHOGRAPH_FILE_SESSION"],
"occurredFrom": "2026-05-01T00:00:00Z",
"occurredTo": "2026-05-31T23:59:59Z",
"expandVehicleEvents": true,
"vehicleExpansionPaddingMinutes": 15
},
"partitioning": {
"strategy": "DRIVER",
"includeAllPartitions": true,
"attachVehicleEvidence": true,
"vehicleEvidencePaddingMinutes": 15
},
"parameters": {
"significantDrivingMinutes": 3,
"minimumRestPeriodMinutes": 720,
"attachVehicleOnlyEvents": true,
"vehicleEvidencePaddingMinutes": 15
}
}
```
## Response shape
```json
{
"profileKey": "tachograph-driver-esper-v1",
"partitioningStrategy": "DRIVER",
"inputEventCount": 1234,
"selectedPartitionCount": 2,
"discoveredVehicleCount": 3,
"partitionResults": {
"12:12345678901234": {
"partitionType": "DRIVER",
"partitionKey": "12:12345678901234",
"resultType": "UnifiedRuntimeDerivedProjectionResultDto",
"result": {
"projection": {
"activityIntervals": [],
"drivingIntervals": [],
"drivingInterruptionIntervals": [],
"dailyWeeklyRestCandidateIntervals": [],
"dailyWeeklyRestCandidateCoverageIntervals": [],
"unclassifiedDailyWeeklyRestCandidateCoverageIntervals": [],
"potentialHomeOvernightStayIntervals": [],
"potentialInVehicleOvernightStayIntervals": [],
"potentialInVehicleTripIntervals": [],
"vehicleUsageIntervals": [],
"vuCardAbsentIntervals": [],
"supportGeoEvents": []
}
}
}
},
"notes": [],
"warnings": []
}
```
## Concepts
### Scope
`scope` is the existing runtime selection model. It can select events from:
```text
TACHOGRAPH_FILE_SESSION
TACHOGRAPH_DB
YELLOWFOX_DB
```
and can use:
```text
SOURCE_DB
EVENTHUB_DB
```
where supported.
For uploaded tachograph files, the scope can use:
```text
sessionId
sessionIds
compositeSessionId
```
### Profile
A profile owns domain-specific processing semantics. It defines:
```text
profile key
expected partitioning strategy
profile-specific parameters
result type
```
Current profile:
```text
tachograph-driver-esper-v1
```
Future profiles can include:
```text
vehicle-stop-detection-v1
vehicle-trip-detection-v1
telematics-poi-clustering-v1
driver-settlement-v1
mixed-driver-vehicle-correlation-v1
```
### Partitioning
The common API supports generic partitioning options:
```text
NONE
DRIVER
VEHICLE
DRIVER_VEHICLE
SOURCE_FAMILY
CUSTOM_PROFILE
```
The first tachograph profile currently supports `DRIVER` partitioning. The service partitions mixed event scopes in Java before invoking Esper so that existing single-driver EPL windows cannot mix driver states.
## Compatibility endpoint
The old tachograph endpoint remains available:
```http
POST /api/eventhub/runtime-processing/tachograph/esper-processing
```
It now acts as a compatibility adapter for:
```http
POST /api/eventhub/runtime-processing/event-processing
```
with:
```text
profileKey = tachograph-driver-esper-v1
partitioning.strategy = DRIVER
```
## Tachograph profile processing flow
```text
runtime event scope
-> broad event assembly
-> driver partition discovery
-> for each driver:
direct driver events
reconstruct driver vehicle-usage intervals
attach only vehicle-scoped events whose vehicle matches and whose timestamp falls inside a usage interval plus configured padding
shared event-input Esper projection pipeline
-> generic partitionResults map
```
The tachograph profile reuses the same `TachographEsperProcessingCore` used by the file-session endpoint. This prevents the file-session API and runtime-processing API from drifting into separate rule chains.
## Vehicle-only evidence attachment
For driver-partitioned profiles, vehicle-only events are no longer attached only by vehicle identity. They are attached to a driver partition only when there is temporal evidence:
```text
vehicle-only event.vehicleKey/registrationKey matches a reconstructed driver vehicle-usage interval
and event.occurredAt is inside [usage.from - padding, usage.to + padding]
```
This prevents unrelated vehicle events from being copied into a driver result simply because the driver used the same vehicle on another day. The tachograph profile currently uses:
```json
{
"partitioning": {
"attachVehicleEvidence": true,
"vehicleEvidencePaddingMinutes": 15
},
"parameters": {
"attachVehicleOnlyEvents": true,
"vehicleEvidencePaddingMinutes": 15
}
}
```
`parameters` take precedence in the tachograph profile. The compatibility endpoint maps these values to `expandVehicleEvents` and `vehicleExpansionPaddingMinutes`.

View File

@ -0,0 +1,49 @@
# Runtime tachograph Esper scope processing
This document is kept for compatibility with earlier patches.
The preferred endpoint is now the generic Runtime Event Processing endpoint:
```http
POST /api/eventhub/runtime-processing/event-processing
```
Use the tachograph profile key:
```text
tachograph-driver-esper-v1
```
The old endpoint remains available as an adapter:
```http
POST /api/eventhub/runtime-processing/tachograph/esper-processing
```
It delegates to the same profile infrastructure used by the generic endpoint. See:
```text
docs/runtime-event-processing.md
```
Vehicle-only evidence is now attached through the same common runtime event-processing profile. The old compatibility endpoint maps:
```json
{
"expandVehicleEvents": true,
"vehicleExpansionPaddingMinutes": 15
}
```
to the generic profile behavior:
```json
{
"parameters": {
"attachVehicleOnlyEvents": true,
"vehicleEvidencePaddingMinutes": 15
}
}
```
Attachment is temporal: a vehicle-only event must match a reconstructed driver vehicle-usage interval and occur inside the interval plus configured padding.

View File

@ -0,0 +1,164 @@
{
"info": {
"name": "EventHub Runtime Event Processing",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"description": "Examples for source-neutral Runtime Event Processing and the tachograph-driver-esper-v1 profile."
},
"variable": [
{
"key": "baseUrl",
"value": "http://localhost:8085"
},
{
"key": "sessionId",
"value": "11111111-1111-1111-1111-111111111111"
},
{
"key": "sessionId2",
"value": "22222222-2222-2222-2222-222222222222"
},
{
"key": "compositeSessionId",
"value": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
},
{
"key": "driverKey",
"value": "12:12345678901234"
}
],
"item": [
{
"name": "List runtime event-processing profiles",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing/profiles",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"runtime-processing",
"event-processing",
"profiles"
]
}
}
},
{
"name": "Execute tachograph profile - single session and one driver",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"sessionId\": \"{{sessionId}}\",\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"driverKey\": \"{{driverKey}}\",\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"partitionKeys\": [\n \"{{driverKey}}\"\n ],\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"runtime-processing",
"event-processing"
]
}
}
},
{
"name": "Execute tachograph profile - multiple sessions all drivers",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"sessionIds\": [\n \"{{sessionId}}\",\n \"{{sessionId2}}\"\n ],\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"includeAllPartitions\": true,\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"runtime-processing",
"event-processing"
]
}
}
},
{
"name": "Execute tachograph profile - composite session all drivers",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"profileKey\": \"tachograph-driver-esper-v1\",\n \"scope\": {\n \"compositeSessionId\": \"{{compositeSessionId}}\",\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15\n },\n \"partitioning\": {\n \"strategy\": \"DRIVER\",\n \"includeAllPartitions\": true,\n \"attachVehicleEvidence\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n },\n \"parameters\": {\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720,\n \"attachVehicleOnlyEvents\": true,\n \"vehicleEvidencePaddingMinutes\": 15\n }\n}"
},
"url": {
"raw": "{{baseUrl}}/api/eventhub/runtime-processing/event-processing",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"runtime-processing",
"event-processing"
]
}
}
},
{
"name": "Compatibility endpoint - tachograph esper processing",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"sessionIds\": [\n \"{{sessionId}}\",\n \"{{sessionId2}}\"\n ],\n \"sourceFamilies\": [\n \"TACHOGRAPH_FILE_SESSION\"\n ],\n \"includeAllDrivers\": true,\n \"occurredFrom\": \"2026-05-01T00:00:00Z\",\n \"occurredTo\": \"2026-05-31T23:59:59Z\",\n \"expandVehicleEvents\": true,\n \"vehicleExpansionPaddingMinutes\": 15,\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720\n}"
},
"url": {
"raw": "{{baseUrl}}/api/eventhub/runtime-processing/tachograph/esper-processing",
"host": [
"{{baseUrl}}"
],
"path": [
"api",
"eventhub",
"runtime-processing",
"tachograph",
"esper-processing"
]
}
}
}
]
}

View File

@ -1,15 +1,26 @@
package at.procon.eventhub.processing.api;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto;
import at.procon.eventhub.processing.eventprocessing.RuntimeEventProcessingService;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingProfileDescriptorDto;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.service.UnifiedRuntimeDerivedProjectionService;
import at.procon.eventhub.processing.service.UnifiedRuntimeDriverTimelineService;
import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService;
import at.procon.eventhub.processing.service.UnifiedRuntimeTachographEsperScopeProcessingService;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/eventhub/runtime-processing")
@ -17,13 +28,31 @@ public class UnifiedRuntimeProcessingController {
private final UnifiedRuntimeEventAssemblyService eventAssemblyService;
private final UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService;
private final UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService;
private final UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService;
private final RuntimeEventProcessingService runtimeEventProcessingService;
public UnifiedRuntimeProcessingController(
UnifiedRuntimeEventAssemblyService eventAssemblyService,
UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService
UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService,
UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService
) {
this(eventAssemblyService, runtimeDriverTimelineService, runtimeDerivedProjectionService, null, null);
}
@Autowired
public UnifiedRuntimeProcessingController(
UnifiedRuntimeEventAssemblyService eventAssemblyService,
UnifiedRuntimeDriverTimelineService runtimeDriverTimelineService,
UnifiedRuntimeDerivedProjectionService runtimeDerivedProjectionService,
UnifiedRuntimeTachographEsperScopeProcessingService tachographEsperScopeProcessingService,
RuntimeEventProcessingService runtimeEventProcessingService
) {
this.eventAssemblyService = eventAssemblyService;
this.runtimeDriverTimelineService = runtimeDriverTimelineService;
this.runtimeDerivedProjectionService = runtimeDerivedProjectionService;
this.tachographEsperScopeProcessingService = tachographEsperScopeProcessingService;
this.runtimeEventProcessingService = runtimeEventProcessingService;
}
@PostMapping("/driver-events")
@ -39,4 +68,45 @@ public class UnifiedRuntimeProcessingController {
) {
return ResponseEntity.ok(runtimeDriverTimelineService.loadDriverTimeline(request.toRuntimeRequest()));
}
@PostMapping("/driver-derived-projections")
public ResponseEntity<UnifiedRuntimeDerivedProjectionResultDto> loadDriverDerivedProjections(
@RequestBody UnifiedRuntimeProcessingApiRequest request
) {
return ResponseEntity.ok(runtimeDerivedProjectionService.loadDriverDerivedProjections(request));
}
@GetMapping("/event-processing/profiles")
public ResponseEntity<List<RuntimeEventProcessingProfileDescriptorDto>> listEventProcessingProfiles() {
if (runtimeEventProcessingService == null) {
throw new IllegalStateException("Runtime event processing service is not configured.");
}
return ResponseEntity.ok(runtimeEventProcessingService.listProfiles());
}
@PostMapping("/event-processing")
public ResponseEntity<RuntimeEventProcessingResultDto> runEventProcessing(
@RequestBody RuntimeEventProcessingApiRequest request
) {
if (runtimeEventProcessingService == null) {
throw new IllegalStateException("Runtime event processing service is not configured.");
}
return ResponseEntity.ok(runtimeEventProcessingService.process(request));
}
@PostMapping("/tachograph/esper-processing")
public ResponseEntity<UnifiedRuntimeTachographEsperScopeResultDto> runTachographEsperProcessing(
@RequestBody UnifiedRuntimeProcessingApiRequest request
) {
if (runtimeEventProcessingService != null) {
RuntimeEventProcessingResultDto genericResult = runtimeEventProcessingService.process(
RuntimeEventProcessingApiRequest.tachographDriverEsper(request)
);
return ResponseEntity.ok(UnifiedRuntimeTachographEsperScopeResultDto.fromGenericRuntimeEventProcessingResult(genericResult));
}
if (tachographEsperScopeProcessingService == null) {
throw new IllegalStateException("Tachograph Esper scope processing service is not configured.");
}
return ResponseEntity.ok(tachographEsperScopeProcessingService.processScope(request));
}
}

View File

@ -1,6 +1,8 @@
package at.procon.eventhub.processing.api;
import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInCompositeSessionException;
import at.procon.eventhub.tachographfilesession.service.DriverNotFoundInSessionException;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionNotFoundException;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionNotFoundException;
import java.time.OffsetDateTime;
import java.util.Map;
@ -14,7 +16,9 @@ public class UnifiedRuntimeProcessingExceptionHandler {
@ExceptionHandler({
TachographFileSessionNotFoundException.class,
DriverNotFoundInSessionException.class
TachographCompositeSessionNotFoundException.class,
DriverNotFoundInSessionException.class,
DriverNotFoundInCompositeSessionException.class
})
public ResponseEntity<Map<String, Object>> notFound(RuntimeException exception) {
return error(HttpStatus.NOT_FOUND, exception);

View File

@ -0,0 +1,22 @@
package at.procon.eventhub.processing.dto;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto;
import java.util.List;
public record UnifiedRuntimeDerivedProjectionResultDto(
UnifiedRuntimeProcessingRequest request,
int driverSeedEventCount,
int discoveredVehicleCount,
int expandedVehicleEventCount,
int mergedEventCount,
List<UnifiedDiscoveredVehicleRef> discoveredVehicles,
TachographEsperDriverProcessingResultDto projection,
List<String> notes
) {
public UnifiedRuntimeDerivedProjectionResultDto {
discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles);
notes = notes == null ? List.of() : List.copyOf(notes);
}
}

View File

@ -4,30 +4,45 @@ import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public record UnifiedRuntimeProcessingApiRequest(
UUID sessionId,
List<UUID> sessionIds,
UUID compositeSessionId,
String tenantKey,
Set<UnifiedEventSourceFamily> sourceFamilies,
UnifiedRuntimeEventBackend eventBackend,
String driverKey,
Set<String> driverKeys,
Boolean includeAllDrivers,
Set<String> vehicleKeys,
Boolean includeAllVehicles,
String driverSourceEntityId,
String driverCardNation,
String driverCardNumber,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo,
Boolean expandVehicleEvents,
Integer vehicleExpansionPaddingMinutes
Integer vehicleExpansionPaddingMinutes,
Integer significantDrivingMinutes,
Integer minimumRestPeriodMinutes
) {
public UnifiedRuntimeProcessingRequest toRuntimeRequest() {
return new UnifiedRuntimeProcessingRequest(
sessionId,
sessionIds,
compositeSessionId,
tenantKey,
sourceFamilies,
eventBackend,
driverKey,
driverKeys,
includeAllDrivers != null && includeAllDrivers,
vehicleKeys,
includeAllVehicles != null && includeAllVehicles,
driverSourceEntityId,
driverCardNation,
driverCardNumber,

View File

@ -0,0 +1,58 @@
package at.procon.eventhub.processing.dto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingPartitionResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record UnifiedRuntimeTachographEsperScopeResultDto(
UnifiedRuntimeProcessingRequest request,
int inputEventCount,
int selectedDriverCount,
int discoveredVehicleCount,
List<UnifiedDiscoveredVehicleRef> discoveredVehicles,
Map<String, UnifiedRuntimeDerivedProjectionResultDto> driverResults,
List<String> notes,
List<String> warnings
) {
public UnifiedRuntimeTachographEsperScopeResultDto {
discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles);
driverResults = driverResults == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(driverResults));
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
public static UnifiedRuntimeTachographEsperScopeResultDto fromGenericRuntimeEventProcessingResult(
RuntimeEventProcessingResultDto genericResult
) {
if (genericResult == null) {
throw new IllegalArgumentException("genericResult must not be null");
}
LinkedHashMap<String, UnifiedRuntimeDerivedProjectionResultDto> driverResults = new LinkedHashMap<>();
for (Map.Entry<String, RuntimeEventProcessingPartitionResultDto> entry : genericResult.partitionResults().entrySet()) {
Object value = entry.getValue().result();
if (value instanceof UnifiedRuntimeDerivedProjectionResultDto projectionResult) {
driverResults.put(entry.getKey(), projectionResult);
} else {
throw new IllegalArgumentException("Cannot convert generic partition result for key "
+ entry.getKey() + " to UnifiedRuntimeDerivedProjectionResultDto: "
+ (value == null ? "null" : value.getClass().getName()));
}
}
return new UnifiedRuntimeTachographEsperScopeResultDto(
genericResult.request(),
genericResult.inputEventCount(),
genericResult.selectedPartitionCount(),
genericResult.discoveredVehicleCount(),
genericResult.discoveredVehicles(),
driverResults,
genericResult.notes(),
genericResult.warnings()
);
}
}

View File

@ -0,0 +1,26 @@
package at.procon.eventhub.processing.eventprocessing;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingProfileDescriptorDto;
import java.util.List;
import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfileRegistry;
import org.springframework.stereotype.Service;
@Service
public class RuntimeEventProcessingService {
private final RuntimeEventProcessingProfileRegistry profileRegistry;
public RuntimeEventProcessingService(RuntimeEventProcessingProfileRegistry profileRegistry) {
this.profileRegistry = profileRegistry;
}
public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) {
return profileRegistry.require(request.profileKey()).process(request);
}
public List<RuntimeEventProcessingProfileDescriptorDto> listProfiles() {
return profileRegistry.profileDescriptors();
}
}

View File

@ -0,0 +1,46 @@
package at.procon.eventhub.processing.eventprocessing.dto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import java.util.Set;
public record RuntimeEventPartitioningApiRequest(
RuntimeEventPartitioningStrategy strategy,
Set<String> partitionKeys,
Boolean includeAllPartitions,
Set<String> driverKeys,
Boolean includeAllDrivers,
Set<String> vehicleKeys,
Boolean includeAllVehicles,
Boolean attachVehicleEvidence,
Integer vehicleEvidencePaddingMinutes
) {
public RuntimeEventPartitioningApiRequest {
strategy = strategy == null ? RuntimeEventPartitioningStrategy.CUSTOM_PROFILE : strategy;
partitionKeys = partitionKeys == null ? Set.of() : Set.copyOf(partitionKeys);
driverKeys = driverKeys == null ? Set.of() : Set.copyOf(driverKeys);
vehicleKeys = vehicleKeys == null ? Set.of() : Set.copyOf(vehicleKeys);
if (vehicleEvidencePaddingMinutes != null && vehicleEvidencePaddingMinutes < 0) {
throw new IllegalArgumentException("vehicleEvidencePaddingMinutes must not be negative");
}
}
public boolean allPartitions() {
return includeAllPartitions != null && includeAllPartitions;
}
public boolean allDrivers() {
return includeAllDrivers != null && includeAllDrivers;
}
public boolean allVehicles() {
return includeAllVehicles != null && includeAllVehicles;
}
public boolean attachVehicleEvidenceOrDefault() {
return attachVehicleEvidence == null || attachVehicleEvidence;
}
public int vehicleEvidencePaddingMinutesOrDefault(int fallback) {
return vehicleEvidencePaddingMinutes == null ? Math.max(0, fallback) : Math.max(0, vehicleEvidencePaddingMinutes);
}
}

View File

@ -0,0 +1,48 @@
package at.procon.eventhub.processing.eventprocessing.dto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public record RuntimeEventProcessingApiRequest(
String profileKey,
UnifiedRuntimeProcessingApiRequest scope,
RuntimeEventPartitioningApiRequest partitioning,
Map<String, Object> parameters
) {
public RuntimeEventProcessingApiRequest {
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank");
}
profileKey = profileKey.trim();
if (scope == null) {
throw new IllegalArgumentException("scope must not be null");
}
partitioning = partitioning == null
? new RuntimeEventPartitioningApiRequest(null, null, null, null, null, null, null, null, null)
: partitioning;
parameters = parameters == null
? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(parameters));
}
public static RuntimeEventProcessingApiRequest tachographDriverEsper(UnifiedRuntimeProcessingApiRequest scope) {
return new RuntimeEventProcessingApiRequest(
"tachograph-driver-esper-v1",
scope,
new RuntimeEventPartitioningApiRequest(
at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy.DRIVER,
null,
scope != null ? scope.includeAllDrivers() : null,
scope != null ? scope.driverKeys() : null,
scope != null ? scope.includeAllDrivers() : null,
scope != null ? scope.vehicleKeys() : null,
scope != null ? scope.includeAllVehicles() : null,
null,
scope != null ? scope.vehicleExpansionPaddingMinutes() : null
),
Map.of()
);
}
}

View File

@ -0,0 +1,17 @@
package at.procon.eventhub.processing.eventprocessing.dto;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
public record RuntimeEventProcessingPartitionResultDto(
String partitionType,
String partitionKey,
String resultType,
Object result,
Map<String, Object> metadata
) {
public RuntimeEventProcessingPartitionResultDto {
metadata = metadata == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(metadata));
}
}

View File

@ -0,0 +1,39 @@
package at.procon.eventhub.processing.eventprocessing.dto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfile;
import java.util.List;
import java.util.Set;
public record RuntimeEventProcessingProfileDescriptorDto(
String profileKey,
String displayName,
String description,
RuntimeEventPartitioningStrategy defaultPartitioningStrategy,
List<RuntimeEventPartitioningStrategy> supportedPartitioningStrategies,
Set<String> requiredParameters,
Set<String> optionalParameters
) {
public RuntimeEventProcessingProfileDescriptorDto {
supportedPartitioningStrategies = supportedPartitioningStrategies == null
? List.of()
: List.copyOf(supportedPartitioningStrategies);
requiredParameters = requiredParameters == null ? Set.of() : Set.copyOf(requiredParameters);
optionalParameters = optionalParameters == null ? Set.of() : Set.copyOf(optionalParameters);
}
public static RuntimeEventProcessingProfileDescriptorDto from(RuntimeEventProcessingProfile profile) {
if (profile == null) {
throw new IllegalArgumentException("profile must not be null");
}
return new RuntimeEventProcessingProfileDescriptorDto(
profile.profileKey(),
profile.displayName(),
profile.description(),
profile.defaultPartitioningStrategy(),
profile.supportedPartitioningStrategies(),
profile.requiredParameters(),
profile.optionalParameters()
);
}
}

View File

@ -0,0 +1,29 @@
package at.procon.eventhub.processing.eventprocessing.dto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record RuntimeEventProcessingResultDto(
String profileKey,
RuntimeEventPartitioningStrategy partitioningStrategy,
UnifiedRuntimeProcessingRequest request,
int inputEventCount,
int selectedPartitionCount,
int discoveredVehicleCount,
List<UnifiedDiscoveredVehicleRef> discoveredVehicles,
Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults,
List<String> notes,
List<String> warnings
) {
public RuntimeEventProcessingResultDto {
discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles);
partitionResults = partitionResults == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(partitionResults));
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
}

View File

@ -0,0 +1,10 @@
package at.procon.eventhub.processing.eventprocessing.partition;
public enum RuntimeEventPartitioningStrategy {
NONE,
DRIVER,
VEHICLE,
DRIVER_VEHICLE,
SOURCE_FAMILY,
CUSTOM_PROFILE
}

View File

@ -0,0 +1,73 @@
package at.procon.eventhub.processing.eventprocessing.partition;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.VehicleRefDto;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.stereotype.Component;
@Component
public class RuntimeEventScopeClassifier {
public RuntimeEventScopeType classify(EventHubEventDto event) {
if (event == null) {
return RuntimeEventScopeType.UNKNOWN;
}
boolean hasDriver = hasDriver(event);
boolean hasVehicle = hasVehicle(event);
if (hasDriver && hasVehicle) {
return RuntimeEventScopeType.DRIVER_VEHICLE_SCOPED;
}
if (hasDriver) {
return RuntimeEventScopeType.DRIVER_SCOPED;
}
if (hasVehicle) {
return RuntimeEventScopeType.VEHICLE_SCOPED;
}
return RuntimeEventScopeType.GLOBAL_SUPPORT;
}
public boolean hasDriver(EventHubEventDto event) {
if (event == null) {
return false;
}
if (text(rawPayload(event), "driverKey") != null) {
return true;
}
DriverRefDto driverRef = event.driverRef();
return driverRef != null && driverRef.hasAnyReference();
}
public boolean hasVehicle(EventHubEventDto event) {
if (event == null) {
return false;
}
JsonNode raw = rawPayload(event);
if (text(raw, "vehicleKey") != null || text(raw, "registrationKey") != null) {
return true;
}
VehicleRefDto vehicleRef = event.vehicleRef();
return vehicleRef != null && vehicleRef.hasAnyReference();
}
private JsonNode rawPayload(EventHubEventDto event) {
JsonNode payload = event.payload();
if (payload == null || payload.isNull()) {
return null;
}
JsonNode raw = payload.get("raw");
return raw == null || raw.isNull() ? payload : raw;
}
private String text(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : text.trim();
}
}

View File

@ -0,0 +1,9 @@
package at.procon.eventhub.processing.eventprocessing.partition;
public enum RuntimeEventScopeType {
DRIVER_SCOPED,
VEHICLE_SCOPED,
DRIVER_VEHICLE_SCOPED,
GLOBAL_SUPPORT,
UNKNOWN
}

View File

@ -0,0 +1,37 @@
package at.procon.eventhub.processing.eventprocessing.profile;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import java.util.List;
import java.util.Set;
public interface RuntimeEventProcessingProfile {
String profileKey();
RuntimeEventPartitioningStrategy defaultPartitioningStrategy();
default String displayName() {
return profileKey();
}
default String description() {
return "";
}
default List<RuntimeEventPartitioningStrategy> supportedPartitioningStrategies() {
RuntimeEventPartitioningStrategy defaultStrategy = defaultPartitioningStrategy();
return defaultStrategy == null ? List.of() : List.of(defaultStrategy);
}
default Set<String> requiredParameters() {
return Set.of();
}
default Set<String> optionalParameters() {
return Set.of();
}
RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request);
}

View File

@ -0,0 +1,49 @@
package at.procon.eventhub.processing.eventprocessing.profile;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingProfileDescriptorDto;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class RuntimeEventProcessingProfileRegistry {
private final Map<String, RuntimeEventProcessingProfile> profilesByKey;
public RuntimeEventProcessingProfileRegistry(List<RuntimeEventProcessingProfile> profiles) {
LinkedHashMap<String, RuntimeEventProcessingProfile> byKey = new LinkedHashMap<>();
for (RuntimeEventProcessingProfile profile : profiles == null ? List.<RuntimeEventProcessingProfile>of() : profiles) {
String key = profile.profileKey();
if (key == null || key.isBlank()) {
throw new IllegalStateException("Runtime event processing profile returned a blank profileKey: " + profile.getClass().getName());
}
RuntimeEventProcessingProfile previous = byKey.putIfAbsent(key, profile);
if (previous != null) {
throw new IllegalStateException("Duplicate runtime event processing profileKey: " + key);
}
}
this.profilesByKey = Collections.unmodifiableMap(byKey);
}
public RuntimeEventProcessingProfile require(String profileKey) {
String normalized = profileKey == null ? null : profileKey.trim();
RuntimeEventProcessingProfile profile = normalized == null ? null : profilesByKey.get(normalized);
if (profile == null) {
throw new IllegalArgumentException("Unknown runtime event processing profileKey: " + profileKey
+ ". Available profiles: " + profilesByKey.keySet());
}
return profile;
}
public Map<String, RuntimeEventProcessingProfile> profilesByKey() {
return profilesByKey;
}
public List<RuntimeEventProcessingProfileDescriptorDto> profileDescriptors() {
return profilesByKey.values().stream()
.map(RuntimeEventProcessingProfileDescriptorDto::from)
.toList();
}
}

View File

@ -0,0 +1,225 @@
package at.procon.eventhub.processing.eventprocessing.profile;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingPartitionResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.service.UnifiedRuntimeTachographEsperScopeProcessingService;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
@Component
public class TachographDriverEsperRuntimeEventProcessingProfile implements RuntimeEventProcessingProfile {
public static final String PROFILE_KEY = "tachograph-driver-esper-v1";
private final UnifiedRuntimeTachographEsperScopeProcessingService tachographScopeProcessingService;
public TachographDriverEsperRuntimeEventProcessingProfile(
UnifiedRuntimeTachographEsperScopeProcessingService tachographScopeProcessingService
) {
this.tachographScopeProcessingService = tachographScopeProcessingService;
}
@Override
public String profileKey() {
return PROFILE_KEY;
}
@Override
public RuntimeEventPartitioningStrategy defaultPartitioningStrategy() {
return RuntimeEventPartitioningStrategy.DRIVER;
}
@Override
public String displayName() {
return "Tachograph Driver Esper Processing";
}
@Override
public String description() {
return "Runs the shared tachograph driver Esper processing pipeline over Runtime Processing event scopes. "
+ "The profile partitions mixed runtime events by driver before invoking the event-input EPL pipeline.";
}
@Override
public List<RuntimeEventPartitioningStrategy> supportedPartitioningStrategies() {
return List.of(RuntimeEventPartitioningStrategy.DRIVER);
}
@Override
public Set<String> optionalParameters() {
return Set.of("significantDrivingMinutes", "minimumRestPeriodMinutes", "attachVehicleOnlyEvents", "vehicleEvidencePaddingMinutes");
}
@Override
public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) {
UnifiedRuntimeProcessingApiRequest tachographScopeRequest = applyGenericRequest(request.scope(), request.partitioning(), request.parameters());
UnifiedRuntimeTachographEsperScopeResultDto tachographResult = tachographScopeProcessingService.processScope(tachographScopeRequest);
Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults = new LinkedHashMap<>();
tachographResult.driverResults().forEach((driverKey, driverResult) -> partitionResults.put(
driverKey,
new RuntimeEventProcessingPartitionResultDto(
"DRIVER",
driverKey,
"UnifiedRuntimeDerivedProjectionResultDto",
driverResult,
Map.of(
"projectionResultType", driverResult.projection() == null ? "NONE" : "TachographEsperDriverProcessingResultDto",
"driverSeedEventCount", driverResult.driverSeedEventCount(),
"expandedVehicleEventCount", driverResult.expandedVehicleEventCount(),
"mergedEventCount", driverResult.mergedEventCount()
)
)
));
return new RuntimeEventProcessingResultDto(
profileKey(),
RuntimeEventPartitioningStrategy.DRIVER,
tachographResult.request(),
tachographResult.inputEventCount(),
tachographResult.selectedDriverCount(),
tachographResult.discoveredVehicleCount(),
tachographResult.discoveredVehicles(),
partitionResults,
tachographResult.notes(),
tachographResult.warnings()
);
}
private UnifiedRuntimeProcessingApiRequest applyGenericRequest(
UnifiedRuntimeProcessingApiRequest scope,
RuntimeEventPartitioningApiRequest partitioning,
Map<String, Object> parameters
) {
if (scope == null) {
throw new IllegalArgumentException("scope must not be null for profile " + PROFILE_KEY + ".");
}
boolean includeAllDrivers = scope.includeAllDrivers() != null && scope.includeAllDrivers();
java.util.Set<String> driverKeys = scope.driverKeys();
boolean includeAllVehicles = scope.includeAllVehicles() != null && scope.includeAllVehicles();
java.util.Set<String> vehicleKeys = scope.vehicleKeys();
if (partitioning != null) {
RuntimeEventPartitioningStrategy strategy = partitioning.strategy() == null
? RuntimeEventPartitioningStrategy.DRIVER
: partitioning.strategy();
if (strategy != RuntimeEventPartitioningStrategy.DRIVER
&& strategy != RuntimeEventPartitioningStrategy.CUSTOM_PROFILE) {
throw new IllegalArgumentException("Profile " + PROFILE_KEY
+ " currently supports DRIVER partitioning only. Requested: " + strategy);
}
includeAllDrivers = includeAllDrivers || partitioning.allPartitions() || partitioning.allDrivers();
if (!partitioning.driverKeys().isEmpty()) {
driverKeys = partitioning.driverKeys();
} else if (!partitioning.partitionKeys().isEmpty()) {
driverKeys = partitioning.partitionKeys();
}
includeAllVehicles = includeAllVehicles || partitioning.allVehicles();
if (!partitioning.vehicleKeys().isEmpty()) {
vehicleKeys = partitioning.vehicleKeys();
}
}
Integer significantDrivingMinutes = integerParameter(parameters, "significantDrivingMinutes", scope.significantDrivingMinutes());
Integer minimumRestPeriodMinutes = integerParameter(parameters, "minimumRestPeriodMinutes", scope.minimumRestPeriodMinutes());
boolean attachVehicleOnlyEvents = booleanParameter(parameters, "attachVehicleOnlyEvents",
partitioning == null ? scope.expandVehicleEvents() == null || scope.expandVehicleEvents() : partitioning.attachVehicleEvidenceOrDefault());
Integer vehicleEvidencePaddingMinutes = nonNegativeIntegerParameter(parameters, "vehicleEvidencePaddingMinutes",
partitioning == null
? scope.vehicleExpansionPaddingMinutes()
: partitioning.vehicleEvidencePaddingMinutesOrDefault(scope.vehicleExpansionPaddingMinutes() == null ? 0 : scope.vehicleExpansionPaddingMinutes()));
return new UnifiedRuntimeProcessingApiRequest(
scope.sessionId(),
scope.sessionIds(),
scope.compositeSessionId(),
scope.tenantKey(),
scope.sourceFamilies(),
scope.eventBackend(),
scope.driverKey(),
driverKeys,
includeAllDrivers,
vehicleKeys,
includeAllVehicles,
scope.driverSourceEntityId(),
scope.driverCardNation(),
scope.driverCardNumber(),
scope.occurredFrom(),
scope.occurredTo(),
attachVehicleOnlyEvents,
vehicleEvidencePaddingMinutes,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
private boolean booleanParameter(Map<String, Object> parameters, String key, boolean fallback) {
if (parameters == null || !parameters.containsKey(key)) {
return fallback;
}
Object value = parameters.get(key);
if (value == null) {
return fallback;
}
if (value instanceof Boolean booleanValue) {
return booleanValue;
}
String text = value.toString();
if (text == null || text.isBlank()) {
return fallback;
}
return Boolean.parseBoolean(text.trim());
}
private Integer nonNegativeIntegerParameter(Map<String, Object> parameters, String key, Integer fallback) {
if (parameters == null || !parameters.containsKey(key)) {
return fallback;
}
Object value = parameters.get(key);
if (value == null) {
return fallback;
}
if (value instanceof Number number) {
return Math.max(0, number.intValue());
}
String text = value.toString();
if (text == null || text.isBlank()) {
return fallback;
}
try {
return Math.max(0, Integer.parseInt(text.trim()));
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("Parameter '" + key + "' must be an integer.", ex);
}
}
private Integer integerParameter(Map<String, Object> parameters, String key, Integer fallback) {
if (parameters == null || !parameters.containsKey(key)) {
return fallback;
}
Object value = parameters.get(key);
if (value == null) {
return fallback;
}
if (value instanceof Number number) {
return Math.max(1, number.intValue());
}
String text = value.toString();
if (text == null || text.isBlank()) {
return fallback;
}
try {
return Math.max(1, Integer.parseInt(text.trim()));
} catch (NumberFormatException ex) {
throw new IllegalArgumentException("Parameter '" + key + "' must be an integer.", ex);
}
}
}

View File

@ -0,0 +1,24 @@
package at.procon.eventhub.processing.model;
import at.procon.eventhub.dto.EventHubEventDto;
import java.util.List;
public record RuntimeDriverVehicleEvidenceAttachmentResult(
String driverKey,
List<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> attachedVehicleEvidenceEvents,
List<EventHubEventDto> mergedEvents,
int vehicleUsageIntervalCount,
int candidateVehicleEvidenceEventCount,
int ignoredVehicleEvidenceEventCount,
List<String> notes,
List<String> warnings
) {
public RuntimeDriverVehicleEvidenceAttachmentResult {
directDriverEvents = directDriverEvents == null ? List.of() : List.copyOf(directDriverEvents);
attachedVehicleEvidenceEvents = attachedVehicleEvidenceEvents == null ? List.of() : List.copyOf(attachedVehicleEvidenceEvents);
mergedEvents = mergedEvents == null ? List.of() : List.copyOf(mergedEvents);
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
}

View File

@ -36,9 +36,6 @@ public record UnifiedDriverEventsRequest(
}
if (sourceFamily == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION) {
Objects.requireNonNull(sessionId, "sessionId must not be null");
if (driverKey == null) {
throw new IllegalArgumentException("driverKey must not be blank");
}
} else {
if (tenantKey == null) {
throw new IllegalArgumentException("tenantKey must not be blank");
@ -50,6 +47,10 @@ public record UnifiedDriverEventsRequest(
}
}
/**
* File-session requests may omit driverKey when the caller intentionally wants
* to load all drivers from the session and partition them later in runtime scope processing.
*/
public static UnifiedDriverEventsRequest forTachographFileSession(
UUID sessionId,
String driverKey,

View File

@ -2,15 +2,24 @@ package at.procon.eventhub.processing.model;
import at.procon.eventhub.reference.DriverCardNumberNormalizer;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public record UnifiedRuntimeProcessingRequest(
UUID sessionId,
List<UUID> sessionIds,
UUID compositeSessionId,
String tenantKey,
Set<UnifiedEventSourceFamily> sourceFamilies,
UnifiedRuntimeEventBackend eventBackend,
String driverKey,
Set<String> driverKeys,
boolean includeAllDrivers,
Set<String> vehicleKeys,
boolean includeAllVehicles,
String driverSourceEntityId,
String driverCardNation,
String driverCardNumber,
@ -20,32 +29,64 @@ public record UnifiedRuntimeProcessingRequest(
int vehicleExpansionPaddingMinutes
) {
public UnifiedRuntimeProcessingRequest {
driverKey = normalize(driverKey);
tenantKey = normalize(tenantKey);
driverSourceEntityId = normalize(driverSourceEntityId);
driverCardNation = normalizeUpper(driverCardNation);
driverCardNumber = normalizeDriverCardNumber(driverCardNumber);
boolean includesFileSession = sourceFamilies != null && sourceFamilies.contains(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION);
boolean includesExternalDb = sourceFamilies != null && sourceFamilies.stream()
.anyMatch(family -> family == UnifiedEventSourceFamily.TACHOGRAPH_DB || family == UnifiedEventSourceFamily.YELLOWFOX_DB);
if (tenantKey == null) {
if (!includesFileSession || includesExternalDb) {
throw new IllegalArgumentException("tenantKey must not be blank");
}
}
if (sourceFamilies == null || sourceFamilies.isEmpty()) {
throw new IllegalArgumentException("sourceFamilies must not be empty");
}
sourceFamilies = Set.copyOf(sourceFamilies);
eventBackend = eventBackend == null ? UnifiedRuntimeEventBackend.SOURCE_DB : eventBackend;
if (includesFileSession && sessionId == null) {
throw new IllegalArgumentException("sessionId must not be null when TACHOGRAPH_FILE_SESSION is selected.");
sessionIds = normalizeSessionIds(sessionId, sessionIds);
if (sessionId == null && !sessionIds.isEmpty()) {
sessionId = sessionIds.get(0);
}
if (includesFileSession && driverKey == null) {
throw new IllegalArgumentException("driverKey must not be blank when TACHOGRAPH_FILE_SESSION is selected.");
if (compositeSessionId != null && !sessionIds.isEmpty()) {
throw new IllegalArgumentException("Use either compositeSessionId or sessionId/sessionIds, not both.");
}
if (includesExternalDb && driverSourceEntityId == null && driverCardNumber == null) {
throw new IllegalArgumentException("At least one driver selector must be provided.");
driverKey = normalize(driverKey);
driverKeys = normalizeStrings(driverKeys);
if (driverKey != null) {
LinkedHashSet<String> mergedDriverKeys = new LinkedHashSet<>(driverKeys);
mergedDriverKeys.add(driverKey);
driverKeys = Set.copyOf(mergedDriverKeys);
}
if (driverKey == null && driverKeys.size() == 1) {
driverKey = driverKeys.iterator().next();
}
vehicleKeys = normalizeStrings(vehicleKeys);
tenantKey = normalize(tenantKey);
driverSourceEntityId = normalize(driverSourceEntityId);
driverCardNation = normalizeUpper(driverCardNation);
driverCardNumber = normalizeDriverCardNumber(driverCardNumber);
boolean includesFileSession = sourceFamilies.contains(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION);
boolean includesExternalDb = sourceFamilies.stream()
.anyMatch(family -> family == UnifiedEventSourceFamily.TACHOGRAPH_DB || family == UnifiedEventSourceFamily.YELLOWFOX_DB);
if (includesFileSession && eventBackend == UnifiedRuntimeEventBackend.EVENTHUB_DB) {
throw new IllegalArgumentException("TACHOGRAPH_FILE_SESSION runtime processing currently supports SOURCE_DB backend only.");
}
if (tenantKey == null) {
if (!includesFileSession || includesExternalDb) {
throw new IllegalArgumentException("tenantKey must not be blank");
}
}
if (includesFileSession && compositeSessionId == null && sessionIds.isEmpty()) {
throw new IllegalArgumentException("sessionId, sessionIds or compositeSessionId must be provided when TACHOGRAPH_FILE_SESSION is selected.");
}
if (includesFileSession && driverKey == null && driverKeys.isEmpty() && !includeAllDrivers) {
throw new IllegalArgumentException("driverKey, driverKeys or includeAllDrivers must be provided when TACHOGRAPH_FILE_SESSION is selected.");
}
if (includesExternalDb && driverSourceEntityId == null && driverCardNumber == null
&& driverKeys.isEmpty() && !includeAllDrivers) {
throw new IllegalArgumentException("At least one driver selector, driverKeys or includeAllDrivers must be provided.");
}
if (includesExternalDb && (includeAllDrivers || !driverKeys.isEmpty())
&& (occurredFrom == null || occurredTo == null)) {
throw new IllegalArgumentException("occurredFrom and occurredTo are required when loading broad external DB runtime scopes.");
}
if (eventBackend == UnifiedRuntimeEventBackend.EVENTHUB_DB
&& includesExternalDb
&& driverSourceEntityId == null
&& driverCardNumber == null
&& (includeAllDrivers || !driverKeys.isEmpty())) {
throw new IllegalArgumentException("Broad multi-driver EVENTHUB_DB runtime scopes are not supported yet; provide a concrete EventHub driver selector or use SOURCE_DB.");
}
if (occurredFrom != null && occurredTo != null && occurredTo.isBefore(occurredFrom)) {
throw new IllegalArgumentException("occurredTo must not be before occurredFrom");
@ -116,11 +157,17 @@ public record UnifiedRuntimeProcessingRequest(
int vehicleExpansionPaddingMinutes
) {
return new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null,
tenantKey,
sourceFamilies,
eventBackend,
null,
Set.of(),
false,
Set.of(),
false,
driverSourceEntityId,
driverCardNation,
driverCardNumber,
@ -143,11 +190,17 @@ public record UnifiedRuntimeProcessingRequest(
int vehicleExpansionPaddingMinutes
) {
return new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null,
tenantKey,
sourceFamilies,
UnifiedRuntimeEventBackend.EVENTHUB_DB,
null,
Set.of(),
false,
Set.of(),
false,
driverSourceEntityId,
driverCardNation,
driverCardNumber,
@ -168,10 +221,76 @@ public record UnifiedRuntimeProcessingRequest(
) {
return new UnifiedRuntimeProcessingRequest(
sessionId,
List.of(),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
UnifiedRuntimeEventBackend.SOURCE_DB,
driverKey,
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
occurredFrom,
occurredTo,
expandVehicleEvents,
vehicleExpansionPaddingMinutes
);
}
public static UnifiedRuntimeProcessingRequest forTachographFileSessions(
List<UUID> sessionIds,
String driverKey,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo,
boolean expandVehicleEvents,
int vehicleExpansionPaddingMinutes
) {
return new UnifiedRuntimeProcessingRequest(
null,
sessionIds,
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
UnifiedRuntimeEventBackend.SOURCE_DB,
driverKey,
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
occurredFrom,
occurredTo,
expandVehicleEvents,
vehicleExpansionPaddingMinutes
);
}
public static UnifiedRuntimeProcessingRequest forTachographCompositeSession(
UUID compositeSessionId,
String driverKey,
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo,
boolean expandVehicleEvents,
int vehicleExpansionPaddingMinutes
) {
return new UnifiedRuntimeProcessingRequest(
null,
List.of(),
compositeSessionId,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
UnifiedRuntimeEventBackend.SOURCE_DB,
driverKey,
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
@ -190,6 +309,58 @@ public record UnifiedRuntimeProcessingRequest(
return occurredTo == null ? null : occurredTo.plusMinutes(vehicleExpansionPaddingMinutes);
}
private static List<UUID> normalizeSessionIds(UUID sessionId, List<UUID> sessionIds) {
LinkedHashSet<UUID> result = new LinkedHashSet<>();
if (sessionId != null) {
result.add(sessionId);
}
if (sessionIds != null) {
result.addAll(sessionIds.stream().filter(value -> value != null).toList());
}
return List.copyOf(new ArrayList<>(result));
}
public UnifiedRuntimeProcessingRequest withDriverKey(String value) {
return new UnifiedRuntimeProcessingRequest(
sessionId,
sessionIds,
compositeSessionId,
tenantKey,
sourceFamilies,
eventBackend,
value,
Set.of(),
false,
vehicleKeys,
includeAllVehicles,
driverSourceEntityId,
driverCardNation,
driverCardNumber,
occurredFrom,
occurredTo,
expandVehicleEvents,
vehicleExpansionPaddingMinutes
);
}
public boolean scopeDriverSelectionRequested() {
return includeAllDrivers || driverKeys.size() > 1 || (driverKey == null && !driverKeys.isEmpty());
}
private static Set<String> normalizeStrings(Set<String> values) {
if (values == null || values.isEmpty()) {
return Set.of();
}
LinkedHashSet<String> normalized = new LinkedHashSet<>();
for (String value : values) {
String normalizedValue = normalize(value);
if (normalizedValue != null) {
normalized.add(normalizedValue);
}
}
return Set.copyOf(normalized);
}
private static String normalize(String value) {
return value == null || value.isBlank() ? null : value.trim();
}

View File

@ -0,0 +1,302 @@
package at.procon.eventhub.processing.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeClassifier;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeType;
import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class RuntimeDriverVehicleEvidenceAttachmentService {
private final UnifiedEventTimelineReconstructor timelineReconstructor;
private final RuntimeEventScopeClassifier scopeClassifier;
public RuntimeDriverVehicleEvidenceAttachmentService(
UnifiedEventTimelineReconstructor timelineReconstructor,
RuntimeEventScopeClassifier scopeClassifier
) {
this.timelineReconstructor = timelineReconstructor;
this.scopeClassifier = scopeClassifier;
}
public RuntimeDriverVehicleEvidenceAttachmentResult attachVehicleEvidence(
String driverKey,
List<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> runtimeScopeEvents,
boolean attachVehicleOnlyEvents,
int vehicleEvidencePaddingMinutes
) {
List<EventHubEventDto> safeDriverEvents = directDriverEvents == null ? List.of() : List.copyOf(directDriverEvents);
List<EventHubEventDto> safeScopeEvents = runtimeScopeEvents == null ? List.of() : List.copyOf(runtimeScopeEvents);
int paddingMinutes = Math.max(0, vehicleEvidencePaddingMinutes);
List<String> notes = new ArrayList<>();
List<String> warnings = new ArrayList<>();
if (!attachVehicleOnlyEvents) {
notes.add("Vehicle-only evidence attachment is disabled for driver partition " + driverKey + ".");
return new RuntimeDriverVehicleEvidenceAttachmentResult(
driverKey,
safeDriverEvents,
List.of(),
deduplicateAndSort(safeDriverEvents, List.of()),
0,
0,
0,
notes,
warnings
);
}
ResolvedDriverTimeline timeline = timelineReconstructor.reconstruct(null, driverKey, safeDriverEvents);
List<ResolvedVehicleUsageInterval> usageIntervals = mergeVehicleUsageIntervals(timeline.vehicleUsageIntervals());
List<EventHubEventDto> candidateVehicleEvidence = safeScopeEvents.stream()
.filter(event -> scopeClassifier.classify(event) == RuntimeEventScopeType.VEHICLE_SCOPED)
.toList();
List<EventHubEventDto> attached = new ArrayList<>();
int ignored = 0;
for (EventHubEventDto vehicleEvent : candidateVehicleEvidence) {
List<ResolvedVehicleUsageInterval> matchingIntervals = matchingUsageIntervals(vehicleEvent, usageIntervals, paddingMinutes);
if (matchingIntervals.isEmpty()) {
ignored++;
continue;
}
attached.add(vehicleEvent);
if (matchingIntervals.size() > 1) {
warnings.add("Vehicle-only event " + vehicleEvent.externalSourceEventId()
+ " matched multiple vehicle-usage intervals for driver " + driverKey
+ "; it was attached once after deduplication.");
}
}
notes.add("Vehicle-only evidence attachment used " + usageIntervals.size()
+ " reconstructed vehicle-usage interval(s) for driver " + driverKey + ".");
notes.add("Vehicle-only evidence padding minutes: " + paddingMinutes + ".");
notes.add("Candidate vehicle-only evidence events: " + candidateVehicleEvidence.size() + ".");
notes.add("Attached vehicle-only evidence events: " + attached.size() + ".");
notes.add("Ignored vehicle-only evidence events: " + ignored + ".");
if (usageIntervals.isEmpty() && !candidateVehicleEvidence.isEmpty()) {
warnings.add("Vehicle-only evidence was available for driver " + driverKey
+ ", but no driver vehicle-usage intervals were reconstructed; no vehicle-only evidence was attached.");
}
return new RuntimeDriverVehicleEvidenceAttachmentResult(
driverKey,
safeDriverEvents,
attached,
deduplicateAndSort(safeDriverEvents, attached),
usageIntervals.size(),
candidateVehicleEvidence.size(),
ignored,
notes,
warnings
);
}
private List<ResolvedVehicleUsageInterval> matchingUsageIntervals(
EventHubEventDto vehicleEvent,
List<ResolvedVehicleUsageInterval> usageIntervals,
int paddingMinutes
) {
if (vehicleEvent == null || vehicleEvent.occurredAt() == null || usageIntervals == null || usageIntervals.isEmpty()) {
return List.of();
}
List<ResolvedVehicleUsageInterval> result = new ArrayList<>();
for (ResolvedVehicleUsageInterval interval : usageIntervals) {
if (matchesVehicle(vehicleEvent, interval) && timeInside(vehicleEvent.occurredAt(), interval, paddingMinutes)) {
result.add(interval);
}
}
return List.copyOf(result);
}
private boolean timeInside(OffsetDateTime occurredAt, ResolvedVehicleUsageInterval interval, int paddingMinutes) {
if (occurredAt == null || interval == null || interval.from() == null) {
return false;
}
OffsetDateTime from = interval.from().minusMinutes(paddingMinutes);
OffsetDateTime to = interval.to() == null ? OffsetDateTime.MAX : interval.to().plusMinutes(paddingMinutes);
return !occurredAt.isBefore(from) && !occurredAt.isAfter(to);
}
private boolean matchesVehicle(EventHubEventDto event, ResolvedVehicleUsageInterval interval) {
if (event == null || interval == null) {
return false;
}
Set<String> eventKeys = vehicleKeys(event);
Set<String> intervalKeys = vehicleKeys(interval);
if (eventKeys.isEmpty() || intervalKeys.isEmpty()) {
return false;
}
return eventKeys.stream().anyMatch(intervalKeys::contains);
}
private Set<String> vehicleKeys(EventHubEventDto event) {
LinkedHashSet<String> result = new LinkedHashSet<>();
JsonNode raw = rawPayload(event);
add(result, text(raw, "vehicleKey"));
add(result, text(raw, "registrationKey"));
VehicleRefDto vehicleRef = event.vehicleRef();
if (vehicleRef != null) {
add(result, vehicleRef.sourceVehicleEntityId());
add(result, vehicleRef.sourceRegistrationEntityId());
add(result, vehicleRef.vin());
if (vehicleRef.vin() != null) {
add(result, "VIN:" + vehicleRef.vin());
}
if (vehicleRef.vehicleRegistration() != null) {
String registrationKey = vehicleRef.vehicleRegistration().stableKey();
add(result, registrationKey);
add(result, "VR:" + registrationKey);
}
}
return Set.copyOf(result);
}
private Set<String> vehicleKeys(ResolvedVehicleUsageInterval interval) {
LinkedHashSet<String> result = new LinkedHashSet<>();
add(result, interval.vehicleKey());
add(result, interval.registrationKey());
if (interval.vehicleKey() != null) {
add(result, "VIN:" + interval.vehicleKey());
}
if (interval.registrationKey() != null) {
add(result, "VR:" + interval.registrationKey());
}
return Set.copyOf(result);
}
private void add(Set<String> keys, String value) {
if (value != null && !value.isBlank()) {
keys.add(value.trim());
}
}
private List<ResolvedVehicleUsageInterval> mergeVehicleUsageIntervals(List<ResolvedVehicleUsageInterval> intervals) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<ResolvedVehicleUsageInterval> sorted = intervals.stream()
.sorted(Comparator.comparing(ResolvedVehicleUsageInterval::from, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(ResolvedVehicleUsageInterval::to, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(ResolvedVehicleUsageInterval::intervalId, Comparator.nullsLast(String::compareTo)))
.toList();
List<ResolvedVehicleUsageInterval> merged = new ArrayList<>();
for (ResolvedVehicleUsageInterval next : sorted) {
if (merged.isEmpty()) {
merged.add(next);
continue;
}
ResolvedVehicleUsageInterval current = merged.get(merged.size() - 1);
if (canMerge(current, next)) {
merged.set(merged.size() - 1, merge(current, next));
} else {
merged.add(next);
}
}
return List.copyOf(merged);
}
private boolean canMerge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) {
if (left == null || right == null || left.to() == null || right.from() == null) {
return false;
}
return Objects.equals(left.driverKey(), right.driverKey())
&& Objects.equals(left.registrationKey(), right.registrationKey())
&& Objects.equals(left.vehicleKey(), right.vehicleKey())
&& !right.from().isAfter(left.to().plusSeconds(1));
}
private ResolvedVehicleUsageInterval merge(ResolvedVehicleUsageInterval left, ResolvedVehicleUsageInterval right) {
LinkedHashSet<String> sourceIntervalIds = new LinkedHashSet<>();
if (left.sourceIntervalIds() != null) {
sourceIntervalIds.addAll(left.sourceIntervalIds());
}
if (right.sourceIntervalIds() != null) {
sourceIntervalIds.addAll(right.sourceIntervalIds());
}
OffsetDateTime end = left.to();
if (right.to() != null && (end == null || right.to().isAfter(end))) {
end = right.to();
}
return ResolvedVehicleUsageInterval.resolved(
left.sessionId(),
left.driverKey(),
left.intervalId(),
left.from(),
end,
left.odometerBeginKm(),
right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(),
left.registrationKey(),
left.vehicleKey(),
left.sourceKind(),
List.copyOf(sourceIntervalIds)
);
}
private List<EventHubEventDto> deduplicateAndSort(
List<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> vehicleEvidenceEvents
) {
LinkedHashMap<String, EventHubEventDto> byKey = new LinkedHashMap<>();
appendDeduplicated(byKey, directDriverEvents);
appendDeduplicated(byKey, vehicleEvidenceEvents);
return sort(new ArrayList<>(byKey.values()));
}
private void appendDeduplicated(LinkedHashMap<String, EventHubEventDto> byKey, List<EventHubEventDto> events) {
for (EventHubEventDto event : events == null ? List.<EventHubEventDto>of() : events) {
byKey.putIfAbsent(dedupKey(event), event);
}
}
private String dedupKey(EventHubEventDto event) {
String sourceKey = event.packageInfo() != null && event.packageInfo().eventSource() != null
? event.packageInfo().eventSource().stableKey()
: "NO_SOURCE";
return sourceKey + "|" + event.externalSourceEventId();
}
private List<EventHubEventDto> sort(List<EventHubEventDto> events) {
return (events == null ? List.<EventHubEventDto>of() : events).stream()
.sorted(Comparator.comparing(EventHubEventDto::occurredAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(event -> event.eventDomain() == null ? "" : event.eventDomain().name())
.thenComparing(event -> event.eventType() == null ? "" : event.eventType().name())
.thenComparing(event -> event.lifecycle() == null ? "" : event.lifecycle().name())
.thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo)))
.toList();
}
private JsonNode rawPayload(EventHubEventDto event) {
JsonNode payload = event.payload();
if (payload == null || payload.isNull()) {
return null;
}
JsonNode raw = payload.get("raw");
return raw == null || raw.isNull() ? payload : raw;
}
private String text(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : text.trim();
}
}

View File

@ -7,7 +7,14 @@ import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.model.UnifiedVehicleEventsRequest;
import at.procon.eventhub.service.EventAcquisitionRecordKeyService;
import at.procon.eventhub.service.EventHubEventSorter;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionNotFoundException;
import at.procon.eventhub.tachographfilesession.service.TachographCompositeSessionRepository;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
@ -15,13 +22,22 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
private final UnifiedDriverEventSourceService driverEventSourceService;
private final UnifiedVehicleEventSourceService vehicleEventSourceService;
private final TachographCompositeSessionRepository compositeSessionRepository;
private final EventAcquisitionRecordKeyService eventKeyService;
private final EventHubEventSorter eventSorter;
public TachographFileSessionRuntimeEventLoader(
UnifiedDriverEventSourceService driverEventSourceService,
UnifiedVehicleEventSourceService vehicleEventSourceService
UnifiedVehicleEventSourceService vehicleEventSourceService,
TachographCompositeSessionRepository compositeSessionRepository,
EventAcquisitionRecordKeyService eventKeyService,
EventHubEventSorter eventSorter
) {
this.driverEventSourceService = driverEventSourceService;
this.vehicleEventSourceService = vehicleEventSourceService;
this.compositeSessionRepository = compositeSessionRepository;
this.eventKeyService = eventKeyService;
this.eventSorter = eventSorter;
}
@Override
@ -32,14 +48,18 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
@Override
public List<EventHubEventDto> loadDriverEvents(UnifiedRuntimeProcessingRequest request) {
return driverEventSourceService.loadDriverEvents(
List<EventHubEventDto> result = new ArrayList<>();
for (UUID sessionId : resolveSessionIds(request)) {
result.addAll(driverEventSourceService.loadDriverEvents(
UnifiedDriverEventsRequest.forTachographFileSession(
request.sessionId(),
sessionId,
request.driverKey(),
request.occurredFrom(),
request.occurredTo()
)
);
));
}
return deduplicateBySignatureAndSort(result);
}
@Override
@ -47,9 +67,11 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
UnifiedRuntimeProcessingRequest request,
UnifiedDiscoveredVehicleRef vehicleRef
) {
return vehicleEventSourceService.loadVehicleEvents(
List<EventHubEventDto> result = new ArrayList<>();
for (UUID sessionId : resolveSessionIds(request)) {
result.addAll(vehicleEventSourceService.loadVehicleEvents(
UnifiedVehicleEventsRequest.forTachographFileSession(
request.sessionId(),
sessionId,
vehicleRef.sourceVehicleEntityId(),
vehicleRef.vin(),
vehicleRef.registrationNation(),
@ -57,6 +79,25 @@ public class TachographFileSessionRuntimeEventLoader implements RuntimeDriverEve
request.vehicleOccurredFrom(),
request.vehicleOccurredTo()
)
);
));
}
return deduplicateBySignatureAndSort(result);
}
private List<UUID> resolveSessionIds(UnifiedRuntimeProcessingRequest request) {
if (request.compositeSessionId() != null) {
return compositeSessionRepository.find(request.compositeSessionId())
.orElseThrow(() -> new TachographCompositeSessionNotFoundException(request.compositeSessionId()))
.memberSessionIds();
}
return request.sessionIds();
}
private List<EventHubEventDto> deduplicateBySignatureAndSort(List<EventHubEventDto> events) {
LinkedHashMap<String, EventHubEventDto> bySignature = new LinkedHashMap<>();
for (EventHubEventDto event : events) {
bySignature.putIfAbsent(eventKeyService.buildEventSignatureHash(event), event);
}
return eventSorter.sort(new ArrayList<>(bySignature.values()));
}
}

View File

@ -36,6 +36,12 @@ public class TachographFileSessionUnifiedDriverEventSource implements UnifiedDri
public List<EventHubEventDto> loadDriverEvents(UnifiedDriverEventsRequest request) {
TachographFileSession session = repository.find(request.sessionId())
.orElseThrow(() -> new TachographFileSessionNotFoundException(request.sessionId()));
if (request.driverKey() == null) {
return session.driversByKey().values().stream()
.flatMap(driver -> eventBuilder.buildEvents(session, driver).stream())
.filter(event -> withinWindow(event.occurredAt(), request.occurredFrom(), request.occurredTo()))
.toList();
}
DriverExtractionSession driver = session.driversByKey().get(request.driverKey());
if (driver == null) {
throw new DriverNotFoundInSessionException(request.sessionId(), request.driverKey());

View File

@ -0,0 +1,472 @@
package at.procon.eventhub.processing.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto;
import at.procon.eventhub.tachographfilesession.model.ExtractedSupportEvent;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingDerivedProjectionBundle;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialInVehicleOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialInVehicleTripIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperSupportGeoEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder;
import at.procon.eventhub.tachographfilesession.service.DriverTimelineReusableProjectionBuilder;
import at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingCore;
import at.procon.eventhub.tachographfilesession.service.TachographEsperProcessingInput;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class UnifiedRuntimeDerivedProjectionService {
private final UnifiedRuntimeEventAssemblyService runtimeEventAssemblyService;
private final UnifiedEventTimelineReconstructor timelineReconstructor;
private final DriverTimelineBuilder driverTimelineBuilder;
private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder;
private final TachographEsperProcessingCore esperProcessingCore;
private final EventHubProperties properties;
public UnifiedRuntimeDerivedProjectionService(
UnifiedRuntimeEventAssemblyService runtimeEventAssemblyService,
UnifiedEventTimelineReconstructor timelineReconstructor,
DriverTimelineBuilder driverTimelineBuilder,
DriverTimelineReusableProjectionBuilder reusableProjectionBuilder,
EventHubProperties properties
) {
this(
runtimeEventAssemblyService,
timelineReconstructor,
driverTimelineBuilder,
reusableProjectionBuilder,
properties,
new TachographEsperProcessingCore(driverTimelineBuilder, reusableProjectionBuilder, properties)
);
}
@Autowired
public UnifiedRuntimeDerivedProjectionService(
UnifiedRuntimeEventAssemblyService runtimeEventAssemblyService,
UnifiedEventTimelineReconstructor timelineReconstructor,
DriverTimelineBuilder driverTimelineBuilder,
DriverTimelineReusableProjectionBuilder reusableProjectionBuilder,
EventHubProperties properties,
TachographEsperProcessingCore esperProcessingCore
) {
this.runtimeEventAssemblyService = runtimeEventAssemblyService;
this.timelineReconstructor = timelineReconstructor;
this.driverTimelineBuilder = driverTimelineBuilder;
this.reusableProjectionBuilder = reusableProjectionBuilder;
this.properties = properties;
this.esperProcessingCore = esperProcessingCore;
}
public UnifiedRuntimeDerivedProjectionResultDto loadDriverDerivedProjections(
UnifiedRuntimeProcessingApiRequest apiRequest
) {
UnifiedRuntimeProcessingRequest request = apiRequest.toRuntimeRequest();
UnifiedRuntimeEventBundle eventBundle = runtimeEventAssemblyService.assembleDriverScopedEvents(request);
return buildDriverDerivedProjection(apiRequest, request, eventBundle, null);
}
public UnifiedRuntimeDerivedProjectionResultDto buildDriverDerivedProjection(
UnifiedRuntimeProcessingApiRequest apiRequest,
UnifiedRuntimeProcessingRequest request,
UnifiedRuntimeEventBundle eventBundle,
String explicitDriverKey
) {
String driverKey = explicitDriverKey == null
? resolveDriverKey(request, eventBundle.mergedEvents())
: explicitDriverKey;
ResolvedDriverTimeline timeline = timelineReconstructor.reconstruct(
runtimeSessionId(request),
driverKey,
eventBundle.mergedEvents()
);
OffsetDateTime requestedFrom = apiRequest.occurredFrom() == null
? timeline.loadedFrom()
: utc(apiRequest.occurredFrom());
OffsetDateTime requestedTo = apiRequest.occurredTo() == null
? timeline.loadedTo()
: utc(apiRequest.occurredTo());
if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) {
throw new IllegalArgumentException("occurredTo must not be before occurredFrom.");
}
int significantDrivingMinutes = apiRequest.significantDrivingMinutes() == null
? processingProperties().getSignificantDrivingMinutes()
: Math.max(1, apiRequest.significantDrivingMinutes());
int minimumRestPeriodMinutes = apiRequest.minimumRestPeriodMinutes() == null
? processingProperties().getMinimumRestPeriodMinutes()
: Math.max(1, apiRequest.minimumRestPeriodMinutes());
List<String> notes = new ArrayList<>(eventBundle.notes());
notes.add("Runtime derived projections were evaluated from the unified merged event stream using the shared tachograph Esper processing core.");
notes.add("Significant driving threshold minutes: " + significantDrivingMinutes + ".");
notes.add("Minimum rest candidate period minutes: " + minimumRestPeriodMinutes + ".");
if (request.occurredFrom() != null || request.occurredTo() != null) {
notes.add("Projection results are filtered to the requested runtime window. For intervals crossing the boundary, include enough source-event padding in the request.");
}
TachographEsperDriverProcessingResultDto projection = esperProcessingCore.process(TachographEsperProcessingInput.fromEvents(
runtimeSessionId(request),
driverKey,
timeline,
eventBundle.mergedEvents(),
requestedFrom,
requestedTo,
significantDrivingMinutes,
minimumRestPeriodMinutes,
notes
));
notes = projection.notes();
return new UnifiedRuntimeDerivedProjectionResultDto(
request,
eventBundle.driverSeedEvents().size(),
eventBundle.discoveredVehicles().size(),
eventBundle.expandedVehicleEvents().size(),
eventBundle.mergedEvents().size(),
eventBundle.discoveredVehicles(),
projection,
notes
);
}
private EventHubProperties.Processing processingProperties() {
return properties.getTachographFileSession().getProcessing();
}
private UUID runtimeSessionId(UnifiedRuntimeProcessingRequest request) {
if (request.compositeSessionId() != null || request.sessionIds().size() > 1) {
return null;
}
return request.sessionIds().size() == 1 ? request.sessionIds().get(0) : request.sessionId();
}
private String resolveDriverKey(
UnifiedRuntimeProcessingRequest request,
List<EventHubEventDto> events
) {
if (request.driverKey() != null) {
return request.driverKey();
}
if (request.driverSourceEntityId() != null) {
return request.driverSourceEntityId();
}
for (EventHubEventDto event : events) {
DriverRefDto driverRef = event.driverRef();
if (driverRef != null && driverRef.hasAnyReference()) {
return driverRef.stableKey();
}
}
if (request.driverCardNation() != null && request.driverCardNumber() != null) {
return request.driverCardNation() + ":" + request.driverCardNumber();
}
return request.driverCardNumber();
}
private List<TachographEsperVehicleUsageIntervalEvent> mergeVehicleUsageIntervals(
List<TachographEsperVehicleUsageIntervalEvent> intervals
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
List<TachographEsperVehicleUsageIntervalEvent> sorted = intervals.stream()
.sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt)
.thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo)))
.toList();
List<TachographEsperVehicleUsageIntervalEvent> merged = new ArrayList<>();
for (TachographEsperVehicleUsageIntervalEvent next : sorted) {
if (merged.isEmpty()) {
merged.add(next);
continue;
}
TachographEsperVehicleUsageIntervalEvent current = merged.get(merged.size() - 1);
if (canMergeVehicleUsage(current, next)) {
merged.set(merged.size() - 1, mergeVehicleUsage(current, next));
} else {
merged.add(next);
}
}
return List.copyOf(merged);
}
private boolean canMergeVehicleUsage(
TachographEsperVehicleUsageIntervalEvent left,
TachographEsperVehicleUsageIntervalEvent right
) {
if (left == null || right == null || left.endedAt() == null || right.startedAt() == null) {
return false;
}
return Objects.equals(left.driverKey(), right.driverKey())
&& Objects.equals(left.registrationKey(), right.registrationKey())
&& Objects.equals(left.vehicleKey(), right.vehicleKey())
&& !right.startedAt().isAfter(left.endedAt().plusSeconds(1));
}
private TachographEsperVehicleUsageIntervalEvent mergeVehicleUsage(
TachographEsperVehicleUsageIntervalEvent left,
TachographEsperVehicleUsageIntervalEvent right
) {
List<String> sourceIntervalIds = new ArrayList<>();
if (left.sourceIntervalIds() != null) {
sourceIntervalIds.addAll(left.sourceIntervalIds());
}
if (right.sourceIntervalIds() != null) {
for (String sourceIntervalId : right.sourceIntervalIds()) {
if (!sourceIntervalIds.contains(sourceIntervalId)) {
sourceIntervalIds.add(sourceIntervalId);
}
}
}
OffsetDateTime end = right.endedAt() == null || right.endedAt().isBefore(left.endedAt())
? left.endedAt()
: right.endedAt();
return new TachographEsperVehicleUsageIntervalEvent(
left.sessionId(),
left.driverKey(),
left.intervalId(),
left.startedAt(),
end,
Duration.between(left.startedAt(), end).getSeconds(),
left.odometerBeginKm(),
right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(),
left.registrationKey(),
left.vehicleKey(),
left.sourceKind(),
sourceIntervalIds
);
}
private List<TachographEsperActivityIntervalEvent> clipActivityIntervals(
List<TachographEsperActivityIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return intervals == null ? List.of() : List.copyOf(intervals);
}
return (intervals == null ? List.<TachographEsperActivityIntervalEvent>of() : intervals).stream()
.map(interval -> {
if (!intersects(interval.startedAt(), interval.endedAt(), requestedFrom, requestedTo)) {
return null;
}
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (start == null || end == null || !end.isAfter(start)) {
return null;
}
boolean clipped = interval.clippedToRequestedPeriod()
|| !Objects.equals(start, interval.startedAt())
|| !Objects.equals(end, interval.endedAt());
return new TachographEsperActivityIntervalEvent(
interval.sessionId(),
interval.driverKey(),
interval.intervalId(),
interval.activityType(),
interval.cardSlot(),
interval.cardStatus(),
interval.drivingStatus(),
interval.registrationKey(),
interval.vehicleKey(),
interval.sourceKind(),
start,
end,
Duration.between(start, end).getSeconds(),
interval.sourceIntervalIds(),
interval.synthetic(),
clipped,
interval.level()
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperActivityIntervalEvent::startedAt)
.thenComparing(TachographEsperActivityIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperVehicleUsageIntervalEvent> clipVehicleUsageIntervals(
List<TachographEsperVehicleUsageIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return intervals == null ? List.of() : List.copyOf(intervals);
}
return (intervals == null ? List.<TachographEsperVehicleUsageIntervalEvent>of() : intervals).stream()
.map(interval -> {
if (!intersects(interval.startedAt(), interval.endedAt(), requestedFrom, requestedTo)) {
return null;
}
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (start == null || end == null || !end.isAfter(start)) {
return null;
}
boolean startClipped = !Objects.equals(start, interval.startedAt());
boolean endClipped = !Objects.equals(end, interval.endedAt());
return new TachographEsperVehicleUsageIntervalEvent(
interval.sessionId(),
interval.driverKey(),
interval.intervalId(),
start,
end,
Duration.between(start, end).getSeconds(),
startClipped ? null : interval.odometerBeginKm(),
endClipped ? null : interval.odometerEndKm(),
interval.registrationKey(),
interval.vehicleKey(),
interval.sourceKind(),
interval.sourceIntervalIds()
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt)
.thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperDrivingInterruptionIntervalEvent> clipDrivingIntervals(
List<TachographEsperDrivingInterruptionIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
return filterIntersecting(
intervals,
requestedFrom,
requestedTo,
TachographEsperDrivingInterruptionIntervalEvent::startedAt,
TachographEsperDrivingInterruptionIntervalEvent::endedAt
);
}
private List<TachographEsperVuCardAbsentIntervalEvent> clipVuCardAbsentIntervals(
List<TachographEsperVuCardAbsentIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
return filterIntersecting(
intervals,
requestedFrom,
requestedTo,
TachographEsperVuCardAbsentIntervalEvent::startedAt,
TachographEsperVuCardAbsentIntervalEvent::endedAt
);
}
private List<TachographEsperSupportGeoEvent> clipSupportGeoEvents(
List<ExtractedSupportEvent> supportEvents,
String driverKey,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
return (supportEvents == null ? List.<ExtractedSupportEvent>of() : supportEvents).stream()
.filter(event -> event.occurredAt() != null)
.filter(event -> driverKey == null || event.driverKey() == null || Objects.equals(driverKey, event.driverKey()))
.filter(event -> requestedFrom == null || !event.occurredAt().isBefore(requestedFrom))
.filter(event -> requestedTo == null || !event.occurredAt().isAfter(requestedTo))
.map(event -> new TachographEsperSupportGeoEvent(
event.eventId(),
event.driverKey(),
event.occurredAt(),
event.eventDomain(),
event.eventType(),
event.eventLifecycle(),
event.registrationKey(),
event.vehicleKey(),
event.country(),
event.region(),
event.countryFrom(),
event.countryTo(),
event.operation(),
event.latitude(),
event.longitude(),
event.odometerKm(),
event.rawRecordPath()
))
.sorted(Comparator.comparing(TachographEsperSupportGeoEvent::occurredAt)
.thenComparing(TachographEsperSupportGeoEvent::eventId, Comparator.nullsLast(String::compareTo)))
.toList();
}
private <T> List<T> filterIntersecting(
List<T> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
TimeAccessor<T> startAccessor,
TimeAccessor<T> endAccessor
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
if (requestedFrom == null || requestedTo == null) {
return List.copyOf(intervals);
}
return intervals.stream()
.filter(interval -> intersects(startAccessor.get(interval), endAccessor.get(interval), requestedFrom, requestedTo))
.toList();
}
private boolean intersects(
OffsetDateTime intervalStart,
OffsetDateTime intervalEnd,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (intervalStart == null || intervalEnd == null || requestedFrom == null || requestedTo == null) {
return false;
}
return intervalEnd.isAfter(requestedFrom) && intervalStart.isBefore(requestedTo);
}
private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isBefore(right) ? left : right;
}
private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
private OffsetDateTime utc(OffsetDateTime value) {
return value == null ? null : value.withOffsetSameInstant(ZoneOffset.UTC);
}
@FunctionalInterface
private interface TimeAccessor<T> {
OffsetDateTime get(T value);
}
}

View File

@ -40,6 +40,15 @@ public class UnifiedRuntimeEventAssemblyService {
notes.add(request.eventBackend() == UnifiedRuntimeEventBackend.EVENTHUB_DB
? "Driver seed events were loaded from the local EventHub event store."
: "Driver seed events were loaded directly from the selected runtime sources.");
if (request.sourceFamilies().contains(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION)) {
if (request.compositeSessionId() != null) {
notes.add("Tachograph file-session events were loaded from composite session " + request.compositeSessionId() + ".");
} else if (request.sessionIds().size() > 1) {
notes.add("Tachograph file-session events were loaded from " + request.sessionIds().size() + " selected sessions.");
} else if (request.sessionId() != null) {
notes.add("Tachograph file-session events were loaded from session " + request.sessionId() + ".");
}
}
if (request.expandVehicleEvents()) {
notes.add("Vehicle expansion loaded additional events for vehicles discovered in the driver seed set.");
notes.add("Vehicle expansion padding minutes: " + request.vehicleExpansionPaddingMinutes() + ".");

View File

@ -0,0 +1,293 @@
package at.procon.eventhub.processing.service;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class UnifiedRuntimeTachographEsperScopeProcessingService {
private final UnifiedRuntimeEventAssemblyService eventAssemblyService;
private final UnifiedRuntimeDerivedProjectionService derivedProjectionService;
private final RuntimeDriverVehicleEvidenceAttachmentService vehicleEvidenceAttachmentService;
public UnifiedRuntimeTachographEsperScopeProcessingService(
UnifiedRuntimeEventAssemblyService eventAssemblyService,
UnifiedRuntimeDerivedProjectionService derivedProjectionService,
RuntimeDriverVehicleEvidenceAttachmentService vehicleEvidenceAttachmentService
) {
this.eventAssemblyService = eventAssemblyService;
this.derivedProjectionService = derivedProjectionService;
this.vehicleEvidenceAttachmentService = vehicleEvidenceAttachmentService;
}
public UnifiedRuntimeTachographEsperScopeResultDto processScope(UnifiedRuntimeProcessingApiRequest apiRequest) {
UnifiedRuntimeProcessingRequest request = apiRequest.toRuntimeRequest();
UnifiedRuntimeEventBundle broadBundle = eventAssemblyService.assembleDriverScopedEvents(request);
LinkedHashSet<String> selectedDriverKeys = selectedDriverKeys(request, broadBundle.mergedEvents());
if (selectedDriverKeys.isEmpty()) {
throw new IllegalArgumentException("No driver partitions could be resolved from the runtime event scope.");
}
Map<String, UnifiedRuntimeDerivedProjectionResultDto> driverResults = new LinkedHashMap<>();
Map<String, List<String>> attachedVehicleEvidenceByEvent = new LinkedHashMap<>();
List<String> warnings = new ArrayList<>();
for (String driverKey : selectedDriverKeys) {
UnifiedRuntimeEventBundle driverBundle = partitionForDriver(request, broadBundle, driverKey);
for (EventHubEventDto attachedEvent : driverBundle.expandedVehicleEvents()) {
attachedVehicleEvidenceByEvent
.computeIfAbsent(dedupKey(attachedEvent), ignored -> new ArrayList<>())
.add(driverKey);
}
driverBundle.notes().stream()
.filter(note -> note.startsWith("WARNING:"))
.map(note -> note.substring("WARNING:".length()).trim())
.forEach(warnings::add);
if (driverBundle.mergedEvents().isEmpty()) {
warnings.add("No events remained after partitioning runtime scope for driver " + driverKey + ".");
continue;
}
UnifiedRuntimeProcessingRequest driverRequest = request.withDriverKey(driverKey);
UnifiedRuntimeDerivedProjectionResultDto driverResult = derivedProjectionService.buildDriverDerivedProjection(
apiRequest,
driverRequest,
driverBundle,
driverKey
);
driverResults.put(driverKey, driverResult);
}
attachedVehicleEvidenceByEvent.forEach((eventKey, drivers) -> {
if (drivers.size() > 1) {
warnings.add("Vehicle-only event " + eventKey + " was attached to multiple driver partitions "
+ drivers + "; check overlapping vehicle-usage intervals.");
}
});
List<String> notes = new ArrayList<>(broadBundle.notes());
notes.add("Runtime tachograph Esper scope processing used Java-side driver partitioning before calling the common event-input Esper pipeline.");
notes.add("Selected driver partitions: " + selectedDriverKeys.size() + ".");
if (!request.includeAllDrivers() && !request.driverKeys().isEmpty()) {
notes.add("The broad runtime event set was filtered to the requested driverKeys.");
}
if (!request.vehicleKeys().isEmpty() || request.includeAllVehicles()) {
notes.add("vehicleKeys/includeAllVehicles are accepted in the request model for source-neutral scopes; driver partitions are enriched only with vehicle evidence that overlaps reconstructed driver vehicle-usage intervals.");
}
notes.add("Vehicle-only evidence attachment is controlled by expandVehicleEvents/attachVehicleOnlyEvents and vehicleExpansionPaddingMinutes/vehicleEvidencePaddingMinutes.");
return new UnifiedRuntimeTachographEsperScopeResultDto(
request,
broadBundle.mergedEvents().size(),
driverResults.size(),
broadBundle.discoveredVehicles().size(),
broadBundle.discoveredVehicles(),
driverResults,
notes,
warnings
);
}
private LinkedHashSet<String> selectedDriverKeys(
UnifiedRuntimeProcessingRequest request,
List<EventHubEventDto> events
) {
LinkedHashSet<String> allDrivers = discoverDriverKeys(events);
if (request.includeAllDrivers()) {
return allDrivers;
}
LinkedHashSet<String> requested = new LinkedHashSet<>(request.driverKeys());
if (request.driverKey() != null) {
requested.add(request.driverKey());
}
if (requested.isEmpty()) {
return allDrivers;
}
LinkedHashSet<String> selected = new LinkedHashSet<>();
for (String driverKey : allDrivers) {
if (requested.contains(driverKey)) {
selected.add(driverKey);
}
}
for (String driverKey : requested) {
selected.add(driverKey);
}
return selected;
}
private UnifiedRuntimeEventBundle partitionForDriver(
UnifiedRuntimeProcessingRequest request,
UnifiedRuntimeEventBundle broadBundle,
String driverKey
) {
List<EventHubEventDto> directDriverEvents = broadBundle.mergedEvents().stream()
.filter(event -> Objects.equals(driverKey(event), driverKey))
.toList();
RuntimeDriverVehicleEvidenceAttachmentResult attachmentResult = vehicleEvidenceAttachmentService.attachVehicleEvidence(
driverKey,
directDriverEvents,
broadBundle.mergedEvents(),
request.expandVehicleEvents(),
request.vehicleExpansionPaddingMinutes()
);
List<UnifiedDiscoveredVehicleRef> driverVehicles = discoverVehicles(attachmentResult.mergedEvents());
List<String> notes = new ArrayList<>(broadBundle.notes());
notes.add("Partitioned mixed runtime event scope for driver " + driverKey + ".");
notes.add("Driver direct events: " + attachmentResult.directDriverEvents().size() + ".");
notes.add("Vehicle-only evidence events attached to driver partition: " + attachmentResult.attachedVehicleEvidenceEvents().size() + ".");
notes.add("Vehicle-usage intervals used for temporal evidence attachment: " + attachmentResult.vehicleUsageIntervalCount() + ".");
notes.addAll(attachmentResult.notes());
attachmentResult.warnings().forEach(warning -> notes.add("WARNING: " + warning));
return new UnifiedRuntimeEventBundle(
request.withDriverKey(driverKey),
attachmentResult.directDriverEvents(),
driverVehicles,
attachmentResult.attachedVehicleEvidenceEvents(),
attachmentResult.mergedEvents(),
notes
);
}
private LinkedHashSet<String> discoverDriverKeys(List<EventHubEventDto> events) {
LinkedHashSet<String> result = new LinkedHashSet<>();
for (EventHubEventDto event : sort(events)) {
String driverKey = driverKey(event);
if (driverKey != null) {
result.add(driverKey);
}
}
return result;
}
private List<UnifiedDiscoveredVehicleRef> discoverVehicles(List<EventHubEventDto> events) {
List<UnifiedDiscoveredVehicleRef> result = new ArrayList<>();
for (EventHubEventDto event : events) {
UnifiedDiscoveredVehicleRef candidate = vehicleRef(event.vehicleRef());
if (candidate == null || !candidate.hasAnyReference()) {
continue;
}
boolean merged = false;
for (int i = 0; i < result.size(); i++) {
UnifiedDiscoveredVehicleRef existing = result.get(i);
if (existing.matches(candidate)) {
result.set(i, existing.merge(candidate));
merged = true;
break;
}
}
if (!merged) {
result.add(candidate);
}
}
result.sort(Comparator.comparing(UnifiedDiscoveredVehicleRef::stableKey));
return List.copyOf(result);
}
private boolean matchesAnyVehicle(VehicleRefDto vehicleRef, List<UnifiedDiscoveredVehicleRef> vehicles) {
UnifiedDiscoveredVehicleRef candidate = vehicleRef(vehicleRef);
if (candidate == null || !candidate.hasAnyReference()) {
return false;
}
return vehicles.stream().anyMatch(vehicle -> vehicle.matches(candidate));
}
private UnifiedDiscoveredVehicleRef vehicleRef(VehicleRefDto vehicleRef) {
if (vehicleRef == null || !vehicleRef.hasAnyReference()) {
return null;
}
return new UnifiedDiscoveredVehicleRef(
vehicleRef.sourceVehicleEntityId(),
vehicleRef.vin(),
vehicleRef.vehicleRegistration() == null
? null
: vehicleRef.vehicleRegistration().nationNumericCode() == null
? vehicleRef.vehicleRegistration().nation()
: vehicleRef.vehicleRegistration().nationNumericCode().toString(),
vehicleRef.vehicleRegistration() == null ? null : vehicleRef.vehicleRegistration().number()
);
}
private List<EventHubEventDto> deduplicateAndSort(
List<EventHubEventDto> directDriverEvents,
List<EventHubEventDto> vehicleEvidenceEvents
) {
LinkedHashMap<String, EventHubEventDto> byKey = new LinkedHashMap<>();
appendDeduplicated(byKey, directDriverEvents);
appendDeduplicated(byKey, vehicleEvidenceEvents);
return sort(new ArrayList<>(byKey.values()));
}
private void appendDeduplicated(LinkedHashMap<String, EventHubEventDto> byKey, List<EventHubEventDto> events) {
for (EventHubEventDto event : events) {
byKey.putIfAbsent(dedupKey(event), event);
}
}
private String dedupKey(EventHubEventDto event) {
String sourceKey = event.packageInfo() != null && event.packageInfo().eventSource() != null
? event.packageInfo().eventSource().stableKey()
: "NO_SOURCE";
return sourceKey + "|" + event.externalSourceEventId();
}
private List<EventHubEventDto> sort(List<EventHubEventDto> events) {
return (events == null ? List.<EventHubEventDto>of() : events).stream()
.sorted(Comparator.comparing(EventHubEventDto::occurredAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(event -> event.eventDomain() == null ? "" : event.eventDomain().name())
.thenComparing(event -> event.eventType() == null ? "" : event.eventType().name())
.thenComparing(event -> event.lifecycle() == null ? "" : event.lifecycle().name())
.thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo)))
.toList();
}
private String driverKey(EventHubEventDto event) {
if (event == null) {
return null;
}
String rawDriverKey = text(rawPayload(event), "driverKey");
if (rawDriverKey != null) {
return rawDriverKey;
}
DriverRefDto driverRef = event.driverRef();
if (driverRef != null && driverRef.hasAnyReference()) {
return driverRef.stableKey();
}
return null;
}
private JsonNode rawPayload(EventHubEventDto event) {
JsonNode payload = event.payload();
if (payload == null || payload.isNull()) {
return null;
}
JsonNode raw = payload.get("raw");
return raw == null || raw.isNull() ? payload : raw;
}
private String text(JsonNode node, String field) {
if (node == null || field == null) {
return null;
}
JsonNode value = node.get(field);
if (value == null || value.isNull()) {
return null;
}
String text = value.asText(null);
return text == null || text.isBlank() ? null : text.trim();
}
}

View File

@ -61,26 +61,23 @@ public class DriverTimelineReusableProjectionBuilder {
private final DriverTimelineBuilder driverTimelineBuilder;
private final RawSourceDriverTimelineEventBuilder rawSourceEventBuilder;
private final UnifiedEventTimelineReconstructor timelineReconstructor;
private final EventHubProperties properties;
public DriverTimelineReusableProjectionBuilder(
DriverTimelineBuilder driverTimelineBuilder,
EventHubProperties properties
) {
this(driverTimelineBuilder, null, new UnifiedEventTimelineReconstructor(), properties);
this(driverTimelineBuilder, null, properties);
}
@Autowired
public DriverTimelineReusableProjectionBuilder(
DriverTimelineBuilder driverTimelineBuilder,
RawSourceDriverTimelineEventBuilder rawSourceEventBuilder,
UnifiedEventTimelineReconstructor timelineReconstructor,
EventHubProperties properties
) {
this.driverTimelineBuilder = driverTimelineBuilder;
this.rawSourceEventBuilder = rawSourceEventBuilder;
this.timelineReconstructor = timelineReconstructor;
this.properties = properties;
}
@ -325,20 +322,17 @@ public class DriverTimelineReusableProjectionBuilder {
String fallbackDriverKey,
List<EventHubEventDto> events
) {
UnifiedEventTimelineReconstructor timelineReconstructor = new UnifiedEventTimelineReconstructor();
ResolvedDriverTimeline reconstructed = timelineReconstructor.reconstruct(
fallbackSessionId == null ? new UUID(0L, 0L) : fallbackSessionId,
fallbackDriverKey,
safeList(events)
);
List<ResolvedVehicleUsageInterval> mergedVehicleUsageIntervals = mergeVehicleUsageIntervals(
reconstructed.vehicleUsageIntervals(),
reconstructed.sourceKind()
);
return new ResolvedDriverTimeline(
reconstructed.sourceKind(),
reconstructed.loadedFrom(),
reconstructed.loadedTo(),
mergedVehicleUsageIntervals,
mergeVehicleUsageIntervals(reconstructed.vehicleUsageIntervals(), reconstructed.sourceKind()),
reconstructed.activityIntervals(),
reconstructed.supportEvents(),
reconstructed.warnings()
@ -411,7 +405,105 @@ public class DriverTimelineReusableProjectionBuilder {
int significantDrivingMinutes,
int minimumRestPeriodMinutes
) {
throw new UnsupportedOperationException("Direct EPL point-input preprocessing is currently disabled.");
if ((activityPointInputEvents == null || activityPointInputEvents.isEmpty())
&& (vehicleUsagePointInputEvents == null || vehicleUsagePointInputEvents.isEmpty())) {
return emptyBundle();
}
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals = new ArrayList<>();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals = new ArrayList<>();
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> dailyWeeklyRestCandidateCoverageIntervals = new ArrayList<>();
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> unclassifiedDailyWeeklyRestCandidateCoverageIntervals = new ArrayList<>();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals = new ArrayList<>();
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = new ArrayList<>();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals = new ArrayList<>();
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> potentialInVehicleOvernightStayIntervals = new ArrayList<>();
List<TachographEsperPotentialInVehicleTripIntervalEvent> potentialInVehicleTripIntervals = new ArrayList<>();
executeWithRuntime(
configuration -> {
configuration.getCommon().addEventType(
"TachographActivityPointInputEvent",
activityPointInputDefinition()
);
configuration.getCommon().addEventType(
"TachographVehicleUsagePointInputEvent",
vehicleUsagePointInputDefinition()
);
configuration.getCommon().addEventType(
"TachographProjectionFinalizeEvent",
projectionFinalizeInputDefinition()
);
configuration.getCommon().addEventType(
"TachographActivityIntervalInputEvent",
activityIntervalInputDefinition()
);
configuration.getCommon().addEventType(
"TachographVehicleUsageIntervalInputEvent",
vehicleUsageIntervalInputDefinition()
);
configuration.getCommon().addEventType(
"TachographSupportGeoEvidenceInputEvent",
supportGeoEvidenceInputDefinition()
);
},
renderDrivingDerivedProjectionEventsEpl(significantDrivingMinutes, minimumRestPeriodMinutes),
Map.of(
"drivingInterruptionIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, drivingInterruptionIntervals),
"dailyWeeklyRestCandidateIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, dailyWeeklyRestCandidateIntervals),
"dailyWeeklyRestCandidateCoverageIntervals", newData -> collectDailyWeeklyRestCandidateCoverageIntervalEvents(newData, dailyWeeklyRestCandidateCoverageIntervals),
"unclassifiedDailyWeeklyRestCandidateCoverageIntervals", newData -> collectDailyWeeklyRestCandidateCoverageIntervalEvents(newData, unclassifiedDailyWeeklyRestCandidateCoverageIntervals),
"drivingInterruptionVehicleChangeIntervals", newData -> collectDrivingInterruptionIntervalEvents(newData, drivingInterruptionVehicleChangeIntervals),
"vuCardAbsentIntervals", newData -> collectVuCardAbsentIntervalEvents(newData, vuCardAbsentIntervals),
"potentialHomeOvernightStayIntervals", newData -> collectPotentialHomeOvernightStayIntervalEvents(newData, potentialHomeOvernightStayIntervals),
"potentialInVehicleOvernightStayIntervals", newData -> collectPotentialInVehicleOvernightStayIntervalEvents(newData, potentialInVehicleOvernightStayIntervals),
"potentialInVehicleTripIntervals", newData -> collectPotentialInVehicleTripIntervalEvents(newData, potentialInVehicleTripIntervals)
),
runtime -> {
if (supportGeoInputEvents != null) {
for (Map<String, Object> supportGeoEvidence : supportGeoInputEvents) {
runtime.getEventService().sendEventMap(
supportGeoEvidence,
"TachographSupportGeoEvidenceInputEvent"
);
}
}
if (vehicleUsagePointInputEvents != null) {
for (Map<String, Object> point : vehicleUsagePointInputEvents) {
runtime.getEventService().sendEventMap(
point,
"TachographVehicleUsagePointInputEvent"
);
}
for (Map<String, Object> finalizeEvent : buildProjectionFinalizeEvents(vehicleUsagePointInputEvents)) {
runtime.getEventService().sendEventMap(
finalizeEvent,
"TachographProjectionFinalizeEvent"
);
}
}
if (activityPointInputEvents != null) {
for (Map<String, Object> point : activityPointInputEvents) {
runtime.getEventService().sendEventMap(
point,
"TachographActivityPointInputEvent"
);
}
}
}
);
return new TachographEsperDrivingDerivedProjectionBundle(
sortDrivingInterruptionIntervals(drivingInterruptionIntervals),
sortDrivingInterruptionIntervals(dailyWeeklyRestCandidateIntervals),
sortDailyWeeklyRestCandidateCoverageIntervals(dailyWeeklyRestCandidateCoverageIntervals),
sortDailyWeeklyRestCandidateCoverageIntervals(unclassifiedDailyWeeklyRestCandidateCoverageIntervals),
sortDrivingInterruptionIntervals(drivingInterruptionVehicleChangeIntervals),
sortVuCardAbsentIntervals(vuCardAbsentIntervals),
sortPotentialHomeOvernightStayIntervals(potentialHomeOvernightStayIntervals),
sortPotentialInVehicleOvernightStayIntervals(potentialInVehicleOvernightStayIntervals),
sortPotentialInVehicleTripIntervals(potentialInVehicleTripIntervals)
);
}
private List<Map<String, Object>> buildProjectionFinalizeEvents(

View File

@ -0,0 +1,760 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto;
import at.procon.eventhub.tachographfilesession.model.*;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import org.springframework.stereotype.Service;
@Service
public class TachographEsperProcessingCore {
private final DriverTimelineBuilder driverTimelineBuilder;
private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder;
private final EventHubProperties properties;
public TachographEsperProcessingCore(
DriverTimelineBuilder driverTimelineBuilder,
DriverTimelineReusableProjectionBuilder reusableProjectionBuilder,
EventHubProperties properties
) {
this.driverTimelineBuilder = driverTimelineBuilder;
this.reusableProjectionBuilder = reusableProjectionBuilder;
this.properties = properties;
}
public TachographEsperDriverProcessingResultDto process(TachographEsperProcessingInput input) {
Objects.requireNonNull(input, "input must not be null");
ResolvedDriverTimeline timeline = Objects.requireNonNull(input.timeline(), "timeline must not be null");
String driverKey = input.driverKey();
OffsetDateTime requestedFrom = input.requestedFrom() == null ? timeline.loadedFrom() : utc(input.requestedFrom());
OffsetDateTime requestedTo = input.requestedTo() == null ? timeline.loadedTo() : utc(input.requestedTo());
if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) {
throw new IllegalArgumentException("occurredTo must not be before occurredFrom.");
}
int significantDrivingMinutes = Math.max(1, input.significantDrivingMinutes());
int minimumRestPeriodMinutes = Math.max(1, input.minimumRestPeriodMinutes());
List<TachographEsperActivityIntervalEvent> activityIntervals = clipEsperActivityIntervalEvents(
driverTimelineBuilder.buildEsperActivityIntervalEvents(input.sessionId(), driverKey, timeline),
requestedFrom,
requestedTo
);
List<TachographEsperActivityIntervalEvent> drivingIntervals = clipEsperActivityIntervalEvents(
driverTimelineBuilder.buildEsperDrivingIntervalEvents(input.sessionId(), driverKey, timeline),
requestedFrom,
requestedTo
);
TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle = buildDerivedProjection(
input,
timeline,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionIntervals =
derivedProjectionBundle.drivingInterruptionIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals =
clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionIntervals, requestedFrom, requestedTo);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDailyWeeklyRestCandidateIntervals =
derivedProjectionBundle.dailyWeeklyRestCandidateIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals =
clipEsperDrivingInterruptionIntervalEvents(rawDailyWeeklyRestCandidateIntervals, requestedFrom, requestedTo);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionVehicleChangeIntervals =
derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals =
clipEsperDrivingInterruptionIntervalEvents(rawDrivingInterruptionVehicleChangeIntervals, requestedFrom, requestedTo);
List<TachographEsperVehicleUsageIntervalEvent> rawVehicleUsageIntervals =
driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline);
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals =
derivedProjectionBundle.vuCardAbsentIntervals();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals =
clipEsperPotentialHomeOvernightStayIntervalEvents(
derivedProjectionBundle.potentialHomeOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> dailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> unclassifiedDailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> potentialInVehicleOvernightStayIntervals =
clipEsperPotentialInVehicleOvernightStayIntervalEvents(
derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleTripIntervalEvent> potentialInVehicleTripIntervals =
clipEsperPotentialInVehicleTripIntervalEvents(
derivedProjectionBundle.potentialInVehicleTripIntervals(),
potentialInVehicleOvernightStayIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVehicleUsageIntervalEvent> vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents(
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents(
rawVuCardAbsentIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperSupportGeoEvent> supportGeoEvents = clipEsperSupportGeoEvents(
timeline.supportEvents(),
driverKey,
requestedFrom,
requestedTo
);
return new TachographEsperDriverProcessingResultDto(
input.sessionId(),
driverKey,
timeline.sourceKind(),
timeline.loadedFrom(),
timeline.loadedTo(),
requestedFrom,
requestedTo,
activityIntervals.size(),
drivingIntervals.size(),
drivingInterruptionIntervals.size(),
drivingInterruptionVehicleChangeIntervals.size(),
dailyWeeklyRestCandidateIntervals.size(),
dailyWeeklyRestCandidateCoverageIntervals.size(),
unclassifiedDailyWeeklyRestCandidateCoverageIntervals.size(),
potentialHomeOvernightStayIntervals.size(),
potentialInVehicleOvernightStayIntervals.size(),
potentialInVehicleTripIntervals.size(),
vehicleUsageIntervals.size(),
vuCardAbsentIntervals.size(),
supportGeoEvents.size(),
activityIntervals,
drivingIntervals,
drivingInterruptionIntervals,
drivingInterruptionVehicleChangeIntervals,
dailyWeeklyRestCandidateIntervals,
dailyWeeklyRestCandidateCoverageIntervals,
unclassifiedDailyWeeklyRestCandidateCoverageIntervals,
potentialHomeOvernightStayIntervals,
potentialInVehicleOvernightStayIntervals,
potentialInVehicleTripIntervals,
vehicleUsageIntervals,
vuCardAbsentIntervals,
supportGeoEvents,
combinedNotes(input.notes())
);
}
private TachographEsperDrivingDerivedProjectionBundle buildDerivedProjection(
TachographEsperProcessingInput input,
ResolvedDriverTimeline timeline,
int significantDrivingMinutes,
int minimumRestPeriodMinutes
) {
if (input.forceEventInput() || input.hasEventInputEvents()) {
return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundleFromEvents(
input.sessionId(),
input.driverKey(),
input.eventInputEvents(),
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
if (properties.getTachographFileSession().getProcessing().getDrivingDerivedProjectionInputMode()
== EventHubProperties.DrivingDerivedProjectionInputMode.EVENTS
&& input.session() != null
&& input.driverSession() != null) {
return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle(
input.session(),
input.driverSession(),
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
return reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle(
input.sessionId(),
input.driverKey(),
timeline,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
}
private List<String> combinedNotes(List<String> extraNotes) {
List<String> notes = new ArrayList<>();
notes.addAll(esperProjectionNotes());
if (extraNotes != null) {
notes.addAll(extraNotes);
}
return List.copyOf(notes);
}
private List<TachographEsperActivityIntervalEvent> clipEsperActivityIntervalEvents(
List<TachographEsperActivityIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
boolean clipped = interval.clippedToRequestedPeriod()
|| !start.equals(interval.startedAt())
|| !end.equals(interval.endedAt());
return new TachographEsperActivityIntervalEvent(
interval.sessionId(),
interval.driverKey(),
interval.intervalId(),
interval.activityType(),
interval.cardSlot(),
interval.cardStatus(),
interval.drivingStatus(),
interval.registrationKey(),
interval.vehicleKey(),
interval.sourceKind(),
start,
end,
Duration.between(start, end).getSeconds(),
interval.sourceIntervalIds(),
interval.synthetic(),
clipped,
interval.level()
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperActivityIntervalEvent::startedAt)
.thenComparing(TachographEsperActivityIntervalEvent::endedAt)
.thenComparing(TachographEsperActivityIntervalEvent::activityType, Comparator.nullsLast(String::compareTo)))
.toList();
}
private List<TachographEsperVehicleUsageIntervalEvent> clipEsperVehicleUsageIntervalEvents(
List<TachographEsperVehicleUsageIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
boolean startClipped = !start.equals(interval.startedAt());
boolean endClipped = !end.equals(interval.endedAt());
return new TachographEsperVehicleUsageIntervalEvent(
interval.sessionId(),
interval.driverKey(),
interval.intervalId(),
start,
end,
Duration.between(start, end).getSeconds(),
startClipped ? null : interval.odometerBeginKm(),
endClipped ? null : interval.odometerEndKm(),
interval.registrationKey(),
interval.vehicleKey(),
interval.sourceKind(),
interval.sourceIntervalIds()
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperVehicleUsageIntervalEvent::startedAt)
.thenComparing(TachographEsperVehicleUsageIntervalEvent::endedAt)
.thenComparing(TachographEsperVehicleUsageIntervalEvent::intervalId, Comparator.nullsLast(String::compareTo)))
.toList();
}
private List<TachographEsperSupportGeoEvent> clipEsperSupportGeoEvents(
List<ExtractedSupportEvent> supportEvents,
String driverKey,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (supportEvents == null || supportEvents.isEmpty() || requestedFrom == null || requestedTo == null) {
return List.of();
}
return supportEvents.stream()
.filter(event -> event.driverKey() == null || Objects.equals(driverKey, event.driverKey()))
.filter(event -> event.occurredAt() != null)
.filter(event -> event.latitude() != null && event.longitude() != null)
.filter(event -> !event.occurredAt().isBefore(requestedFrom) && !event.occurredAt().isAfter(requestedTo))
.map(event -> new TachographEsperSupportGeoEvent(
event.eventId(),
event.driverKey(),
event.occurredAt(),
event.eventDomain(),
event.eventType(),
event.eventLifecycle(),
event.registrationKey(),
event.vehicleKey(),
event.country(),
event.region(),
event.countryFrom(),
event.countryTo(),
event.operation(),
event.latitude(),
event.longitude(),
event.odometerKm(),
event.rawRecordPath()
))
.sorted(Comparator.comparing(TachographEsperSupportGeoEvent::occurredAt)
.thenComparing(TachographEsperSupportGeoEvent::eventDomain, Comparator.nullsLast(String::compareTo))
.thenComparing(TachographEsperSupportGeoEvent::eventId, Comparator.nullsLast(String::compareTo)))
.toList();
}
private List<TachographEsperDrivingInterruptionIntervalEvent> clipEsperDrivingInterruptionIntervalEvents(
List<TachographEsperDrivingInterruptionIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
return new TachographEsperDrivingInterruptionIntervalEvent(
interval.sessionId(),
interval.driverKey(),
start,
end,
Duration.between(start, end).getSeconds(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey()
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperDrivingInterruptionIntervalEvent::startedAt)
.thenComparing(TachographEsperDrivingInterruptionIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperVuCardAbsentIntervalEvent> clipEsperVuCardAbsentIntervalEvents(
List<TachographEsperVuCardAbsentIntervalEvent> intervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
return new TachographEsperVuCardAbsentIntervalEvent(
interval.sessionId(),
interval.driverKey(),
start,
end,
Duration.between(start, end).getSeconds(),
interval.previousUsageIntervalId(),
interval.nextUsageIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey()
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperVuCardAbsentIntervalEvent::startedAt)
.thenComparing(TachographEsperVuCardAbsentIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> intervals,
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals,
List<TachographEsperVehicleUsageIntervalEvent> rawVehicleUsageIntervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
long durationSeconds = Duration.between(start, end).getSeconds();
boolean beginBoundaryChanged = !start.equals(interval.startedAt());
boolean endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent(
interval.sessionId(),
interval.driverKey(),
start,
end,
durationSeconds,
interval.cardAbsentDurationSeconds(),
interval.cardAbsentCoveragePercent(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
beginBoundaryChanged ? null : interval.beginLatitude(),
beginBoundaryChanged ? null : interval.beginLongitude(),
beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(),
beginBoundaryChanged ? null : interval.beginGeoOdometerKm(),
endBoundaryChanged ? null : interval.endGeoEventId(),
endBoundaryChanged ? null : interval.endGeoEventDomain(),
endBoundaryChanged ? null : interval.endGeoOccurredAt(),
endBoundaryChanged ? null : interval.endLatitude(),
endBoundaryChanged ? null : interval.endLongitude(),
endBoundaryChanged ? null : interval.endGeoDistanceSeconds(),
endBoundaryChanged ? null : interval.endGeoOdometerKm(),
beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(),
beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(),
beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()),
endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm())
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::startedAt)
.thenComparing(TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperPotentialHomeOvernightStayIntervalEvent> clipEsperPotentialHomeOvernightStayIntervalEvents(
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals,
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals,
List<TachographEsperVehicleUsageIntervalEvent> rawVehicleUsageIntervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
long durationSeconds = Duration.between(start, end).getSeconds();
boolean beginBoundaryChanged = !start.equals(interval.startedAt());
boolean endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperPotentialHomeOvernightStayIntervalEvent(
interval.sessionId(),
interval.driverKey(),
start,
end,
durationSeconds,
interval.cardAbsentDurationSeconds(),
interval.cardAbsentCoveragePercent(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
beginBoundaryChanged ? null : interval.beginLatitude(),
beginBoundaryChanged ? null : interval.beginLongitude(),
beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(),
beginBoundaryChanged ? null : interval.beginGeoOdometerKm(),
endBoundaryChanged ? null : interval.endGeoEventId(),
endBoundaryChanged ? null : interval.endGeoEventDomain(),
endBoundaryChanged ? null : interval.endGeoOccurredAt(),
endBoundaryChanged ? null : interval.endLatitude(),
endBoundaryChanged ? null : interval.endLongitude(),
endBoundaryChanged ? null : interval.endGeoDistanceSeconds(),
endBoundaryChanged ? null : interval.endGeoOdometerKm(),
beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(),
beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(),
beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()),
endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm())
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> clipEsperPotentialInVehicleOvernightStayIntervalEvents(
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> intervals,
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals,
List<TachographEsperVehicleUsageIntervalEvent> rawVehicleUsageIntervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
long durationSeconds = Duration.between(start, end).getSeconds();
boolean beginBoundaryChanged = !start.equals(interval.startedAt());
boolean endBoundaryChanged = !end.equals(interval.endedAt());
return new TachographEsperPotentialInVehicleOvernightStayIntervalEvent(
interval.sessionId(),
interval.driverKey(),
start,
end,
durationSeconds,
interval.cardAbsentDurationSeconds(),
interval.cardAbsentCoveragePercent(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey(),
beginBoundaryChanged ? null : interval.beginBoundaryOdometerKm(),
endBoundaryChanged ? null : interval.endBoundaryOdometerKm(),
beginBoundaryChanged ? null : interval.beginGeoEventId(),
beginBoundaryChanged ? null : interval.beginGeoEventDomain(),
beginBoundaryChanged ? null : interval.beginGeoOccurredAt(),
beginBoundaryChanged ? null : interval.beginLatitude(),
beginBoundaryChanged ? null : interval.beginLongitude(),
beginBoundaryChanged ? null : interval.beginGeoDistanceSeconds(),
beginBoundaryChanged ? null : interval.beginGeoOdometerKm(),
endBoundaryChanged ? null : interval.endGeoEventId(),
endBoundaryChanged ? null : interval.endGeoEventDomain(),
endBoundaryChanged ? null : interval.endGeoOccurredAt(),
endBoundaryChanged ? null : interval.endLatitude(),
endBoundaryChanged ? null : interval.endLongitude(),
endBoundaryChanged ? null : interval.endGeoDistanceSeconds(),
endBoundaryChanged ? null : interval.endGeoOdometerKm(),
beginBoundaryChanged || endBoundaryChanged ? null : interval.geoEvidenceMovementMeters(),
beginBoundaryChanged || endBoundaryChanged ? "UNKNOWN" : interval.geoEvidenceMovementCategory(),
beginBoundaryChanged ? null : geoEvidenceEvent(interval.beginGeoEventId(), interval.beginGeoEventDomain(), interval.beginGeoOccurredAt(), interval.beginLatitude(), interval.beginLongitude(), interval.beginGeoDistanceSeconds(), interval.beginGeoOdometerKm()),
endBoundaryChanged ? null : geoEvidenceEvent(interval.endGeoEventId(), interval.endGeoEventDomain(), interval.endGeoOccurredAt(), interval.endLatitude(), interval.endLongitude(), interval.endGeoDistanceSeconds(), interval.endGeoOdometerKm())
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt))
.toList();
}
private List<TachographEsperPotentialInVehicleTripIntervalEvent> clipEsperPotentialInVehicleTripIntervalEvents(
List<TachographEsperPotentialInVehicleTripIntervalEvent> intervals,
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> potentialInVehicleOvernightStayIntervals,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo
) {
if (requestedFrom == null || requestedTo == null) {
return List.of();
}
if (intervals == null || intervals.isEmpty()
|| potentialInVehicleOvernightStayIntervals == null || potentialInVehicleOvernightStayIntervals.isEmpty()) {
return List.of();
}
return intervals.stream()
.map(interval -> {
OffsetDateTime start = max(interval.startedAt(), requestedFrom);
OffsetDateTime end = min(interval.endedAt(), requestedTo);
if (!end.isAfter(start)) {
return null;
}
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> containedIntervals =
potentialInVehicleOvernightStayIntervals.stream()
.filter(candidate -> tripContainsPotentialInterval(
interval.driverKey(),
interval.registrationKey(),
interval.vehicleKey(),
start,
end,
candidate
))
.sorted(Comparator.comparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::endedAt))
.toList();
if (containedIntervals.isEmpty()) {
return null;
}
TachographEsperPotentialInVehicleOvernightStayIntervalEvent first = containedIntervals.get(0);
TachographEsperPotentialInVehicleOvernightStayIntervalEvent last =
containedIntervals.get(containedIntervals.size() - 1);
return new TachographEsperPotentialInVehicleTripIntervalEvent(
interval.sessionId(),
interval.driverKey(),
start,
end,
Duration.between(start, end).getSeconds(),
interval.registrationKey(),
interval.vehicleKey(),
containedIntervals.size(),
containedIntervals.stream()
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::durationSeconds)
.sum(),
containedIntervals.stream()
.mapToLong(TachographEsperPotentialInVehicleOvernightStayIntervalEvent::cardAbsentDurationSeconds)
.sum(),
first.startedAt(),
last.endedAt(),
first.previousDrivingSourceIntervalId(),
last.nextDrivingSourceIntervalId(),
containedIntervals
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperPotentialInVehicleTripIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialInVehicleTripIntervalEvent::endedAt))
.toList();
}
private boolean tripContainsPotentialInterval(
String driverKey,
String registrationKey,
String vehicleKey,
OffsetDateTime tripStartedAt,
OffsetDateTime tripEndedAt,
TachographEsperPotentialInVehicleOvernightStayIntervalEvent candidate
) {
if (!Objects.equals(driverKey, candidate.driverKey())) {
return false;
}
if (!Objects.equals(registrationKey, candidate.previousRegistrationKey())) {
return false;
}
if (vehicleKey != null && candidate.previousVehicleKey() != null
&& !Objects.equals(vehicleKey, candidate.previousVehicleKey())) {
return false;
}
return !candidate.startedAt().isBefore(tripStartedAt)
&& !candidate.endedAt().isAfter(tripEndedAt);
}
private List<String> esperProjectionNotes() {
return List.of(
"This endpoint returns Esper-backed per-driver interval projections from the in-memory tachograph file-session model.",
"Driving intervals are a filtered projection of activity intervals where activityType = DRIVE.",
"Driving interruption intervals are gaps between consecutive driving intervals longer than the configured significant-driving threshold.",
"Driving interruption vehicle-change intervals are daily/weekly rest candidates where previousRegistrationKey differs from nextRegistrationKey.",
"Daily/weekly rest candidate intervals are driving interruption intervals longer than the configured minimum rest-period threshold.",
"Daily/weekly rest candidate coverage intervals enrich each rest candidate with card-present and card-absent coverage metrics computed from vehicle-usage and VU card-absent overlap.",
"Daily/weekly rest candidate coverage intervals also attach begin/end geo evidence from nearby support events for the same driver and boundary-side vehicle identity.",
"Boundary geo evidence prefers the nearest matching POSITION event, then PLACE, BORDER_CROSSING, and LOAD_UNLOAD within the configured lookback/lookahead windows.",
"If both begin and end geo evidence carry odometer values, geoEvidenceMovementCategory classifies the interval as STATIONARY, MINOR, MOVED, or UNKNOWN.",
"Unclassified daily/weekly rest candidate coverage intervals are the rest candidates that are neither potential home overnight stays nor potential in-vehicle overnight stays.",
"Potential home overnight stay intervals are vehicle-change daily/weekly rest candidate coverage intervals where VU card-absent overlap covers at least 95% of the candidate interval.",
"Potential in-vehicle overnight stay intervals are no-change daily/weekly rest candidate coverage intervals where card-present overlap covers the candidate rest period.",
"Potential in-vehicle trip intervals span from the end of the coverage interval before a same-vehicle in-vehicle-overnight sequence to the start of the first coverage interval after that sequence.",
"VU card-absent intervals are gaps between consecutive normalized vehicle-usage intervals for the same driver.",
"occurredFrom and occurredTo clip the returned interval projections to the requested UTC time window.",
"Vehicle-usage intervals clear clipped odometer endpoints because boundary odometer values cannot be recomputed safely from the source interval."
);
}
private OffsetDateTime utc(OffsetDateTime value) {
return value == null ? null : value.withOffsetSameInstant(java.time.ZoneOffset.UTC);
}
private TachographEsperGeoEvidenceEvent geoEvidenceEvent(
String eventId,
String eventDomain,
OffsetDateTime occurredAt,
Double latitude,
Double longitude,
Long distanceSeconds,
Long odometerKm
) {
if (eventId == null
&& eventDomain == null
&& occurredAt == null
&& latitude == null
&& longitude == null
&& distanceSeconds == null
&& odometerKm == null) {
return null;
}
return new TachographEsperGeoEvidenceEvent(
eventId,
eventDomain,
occurredAt,
latitude,
longitude,
distanceSeconds,
odometerKm
);
}
private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isBefore(right) ? left : right;
}
}

View File

@ -0,0 +1,88 @@
package at.procon.eventhub.tachographfilesession.service;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
public record TachographEsperProcessingInput(
UUID sessionId,
String driverKey,
TachographFileSession session,
DriverExtractionSession driverSession,
ResolvedDriverTimeline timeline,
List<EventHubEventDto> eventInputEvents,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
int significantDrivingMinutes,
int minimumRestPeriodMinutes,
boolean forceEventInput,
List<String> notes
) {
public TachographEsperProcessingInput {
eventInputEvents = eventInputEvents == null ? List.of() : List.copyOf(eventInputEvents);
significantDrivingMinutes = Math.max(1, significantDrivingMinutes);
minimumRestPeriodMinutes = Math.max(1, minimumRestPeriodMinutes);
notes = notes == null ? List.of() : List.copyOf(notes);
}
public static TachographEsperProcessingInput fromFileSession(
TachographFileSession session,
DriverExtractionSession driverSession,
ResolvedDriverTimeline timeline,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
int significantDrivingMinutes,
int minimumRestPeriodMinutes,
List<String> notes
) {
return new TachographEsperProcessingInput(
session == null ? null : session.sessionId(),
driverSession == null ? null : driverSession.driverKey(),
session,
driverSession,
timeline,
List.of(),
requestedFrom,
requestedTo,
significantDrivingMinutes,
minimumRestPeriodMinutes,
false,
notes
);
}
public static TachographEsperProcessingInput fromEvents(
UUID sessionId,
String driverKey,
ResolvedDriverTimeline timeline,
List<EventHubEventDto> eventInputEvents,
OffsetDateTime requestedFrom,
OffsetDateTime requestedTo,
int significantDrivingMinutes,
int minimumRestPeriodMinutes,
List<String> notes
) {
return new TachographEsperProcessingInput(
sessionId,
driverKey,
null,
null,
timeline,
eventInputEvents,
requestedFrom,
requestedTo,
significantDrivingMinutes,
minimumRestPeriodMinutes,
true,
notes
);
}
public boolean hasEventInputEvents() {
return !eventInputEvents.isEmpty();
}
}

View File

@ -33,6 +33,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
@ -42,6 +43,7 @@ public class TachographFileSessionProcessingService {
private final DriverTimelineBuilder driverTimelineBuilder;
private final DriverTimelineReusableProjectionBuilder reusableProjectionBuilder;
private final EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder;
private final TachographEsperProcessingCore esperProcessingCore;
private final EventHubProperties properties;
public TachographFileSessionProcessingService(
@ -50,12 +52,32 @@ public class TachographFileSessionProcessingService {
DriverTimelineReusableProjectionBuilder reusableProjectionBuilder,
EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder,
EventHubProperties properties
) {
this(
repository,
driverTimelineBuilder,
reusableProjectionBuilder,
eventBackedDriverTimelineBuilder,
properties,
new TachographEsperProcessingCore(driverTimelineBuilder, reusableProjectionBuilder, properties)
);
}
@Autowired
public TachographFileSessionProcessingService(
TachographFileSessionRepository repository,
DriverTimelineBuilder driverTimelineBuilder,
DriverTimelineReusableProjectionBuilder reusableProjectionBuilder,
EventBackedDriverTimelineBuilder eventBackedDriverTimelineBuilder,
EventHubProperties properties,
TachographEsperProcessingCore esperProcessingCore
) {
this.repository = repository;
this.driverTimelineBuilder = driverTimelineBuilder;
this.reusableProjectionBuilder = reusableProjectionBuilder;
this.eventBackedDriverTimelineBuilder = eventBackedDriverTimelineBuilder;
this.properties = properties;
this.esperProcessingCore = esperProcessingCore;
}
public TachographOperatingPeriodsProcessingResultDto evaluateOperatingPeriods(
@ -156,160 +178,26 @@ public class TachographFileSessionProcessingService {
}
ResolvedDriverTimeline timeline = resolveTimeline(session, driver);
OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null ? timeline.loadedFrom() : utc(effectiveRequest.occurredFrom());
OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null ? timeline.loadedTo() : utc(effectiveRequest.occurredTo());
OffsetDateTime requestedFrom = effectiveRequest.occurredFrom() == null
? timeline.loadedFrom()
: utc(effectiveRequest.occurredFrom());
OffsetDateTime requestedTo = effectiveRequest.occurredTo() == null
? timeline.loadedTo()
: utc(effectiveRequest.occurredTo());
if (requestedFrom != null && requestedTo != null && requestedTo.isBefore(requestedFrom)) {
throw new IllegalArgumentException("occurredTo must not be before occurredFrom.");
}
int significantDrivingMinutes = resolveEsperSignificantDrivingMinutes(effectiveRequest);
int minimumRestPeriodMinutes = resolveMinimumRestPeriodMinutes(effectiveRequest);
List<TachographEsperActivityIntervalEvent> activityIntervals = clipEsperActivityIntervalEvents(
driverTimelineBuilder.buildEsperActivityIntervalEvents(sessionId, driverKey, timeline),
requestedFrom,
requestedTo
);
List<TachographEsperActivityIntervalEvent> drivingIntervals = clipEsperActivityIntervalEvents(
driverTimelineBuilder.buildEsperDrivingIntervalEvents(sessionId, driverKey, timeline),
requestedFrom,
requestedTo
);
TachographEsperDrivingDerivedProjectionBundle derivedProjectionBundle =
properties.getTachographFileSession().getProcessing().getDrivingDerivedProjectionInputMode()
== EventHubProperties.DrivingDerivedProjectionInputMode.EVENTS
? reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle(
return esperProcessingCore.process(TachographEsperProcessingInput.fromFileSession(
session,
driver,
significantDrivingMinutes,
minimumRestPeriodMinutes
)
: reusableProjectionBuilder.buildEsperDrivingDerivedProjectionBundle(
sessionId,
driverKey,
timeline,
significantDrivingMinutes,
minimumRestPeriodMinutes
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionIntervals =
derivedProjectionBundle.drivingInterruptionIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDrivingInterruptionIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDailyWeeklyRestCandidateIntervals =
derivedProjectionBundle.dailyWeeklyRestCandidateIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> dailyWeeklyRestCandidateIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDailyWeeklyRestCandidateIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionVehicleChangeIntervals =
derivedProjectionBundle.drivingInterruptionVehicleChangeIntervals();
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDrivingInterruptionVehicleChangeIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVehicleUsageIntervalEvent> rawVehicleUsageIntervals =
driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline);
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals =
derivedProjectionBundle.vuCardAbsentIntervals();
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals =
clipEsperPotentialHomeOvernightStayIntervalEvents(
derivedProjectionBundle.potentialHomeOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> dailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.dailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent> unclassifiedDailyWeeklyRestCandidateCoverageIntervals =
clipEsperDailyWeeklyRestCandidateCoverageIntervalEvents(
derivedProjectionBundle.unclassifiedDailyWeeklyRestCandidateCoverageIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleOvernightStayIntervalEvent> potentialInVehicleOvernightStayIntervals =
clipEsperPotentialInVehicleOvernightStayIntervalEvents(
derivedProjectionBundle.potentialInVehicleOvernightStayIntervals(),
rawVuCardAbsentIntervals,
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperPotentialInVehicleTripIntervalEvent> potentialInVehicleTripIntervals =
clipEsperPotentialInVehicleTripIntervalEvents(
derivedProjectionBundle.potentialInVehicleTripIntervals(),
potentialInVehicleOvernightStayIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVehicleUsageIntervalEvent> vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents(
rawVehicleUsageIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents(
rawVuCardAbsentIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperSupportGeoEvent> supportGeoEvents = clipEsperSupportGeoEvents(
timeline.supportEvents(),
driverKey,
requestedFrom,
requestedTo
);
return new TachographEsperDriverProcessingResultDto(
sessionId,
driverKey,
timeline.sourceKind(),
timeline.loadedFrom(),
timeline.loadedTo(),
requestedFrom,
requestedTo,
activityIntervals.size(),
drivingIntervals.size(),
drivingInterruptionIntervals.size(),
drivingInterruptionVehicleChangeIntervals.size(),
dailyWeeklyRestCandidateIntervals.size(),
dailyWeeklyRestCandidateCoverageIntervals.size(),
unclassifiedDailyWeeklyRestCandidateCoverageIntervals.size(),
potentialHomeOvernightStayIntervals.size(),
potentialInVehicleOvernightStayIntervals.size(),
potentialInVehicleTripIntervals.size(),
vehicleUsageIntervals.size(),
vuCardAbsentIntervals.size(),
supportGeoEvents.size(),
activityIntervals,
drivingIntervals,
drivingInterruptionIntervals,
drivingInterruptionVehicleChangeIntervals,
dailyWeeklyRestCandidateIntervals,
dailyWeeklyRestCandidateCoverageIntervals,
unclassifiedDailyWeeklyRestCandidateCoverageIntervals,
potentialHomeOvernightStayIntervals,
potentialInVehicleOvernightStayIntervals,
potentialInVehicleTripIntervals,
vehicleUsageIntervals,
vuCardAbsentIntervals,
supportGeoEvents,
esperProjectionNotes()
);
resolveEsperSignificantDrivingMinutes(effectiveRequest),
resolveMinimumRestPeriodMinutes(effectiveRequest),
List.of()
));
}
private ResolvedDriverTimeline resolveTimeline(

View File

@ -2,6 +2,7 @@ package at.procon.eventhub.processing.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.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -14,13 +15,21 @@ import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto;
import at.procon.eventhub.processing.eventprocessing.RuntimeEventProcessingService;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingProfileDescriptorDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingPartitionResultDto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBackend;
import at.procon.eventhub.processing.model.UnifiedRuntimeEventBundle;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.service.UnifiedRuntimeDerivedProjectionService;
import at.procon.eventhub.processing.service.UnifiedRuntimeDriverTimelineService;
import at.procon.eventhub.processing.service.UnifiedRuntimeEventAssemblyService;
import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto;
import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
@ -48,7 +57,8 @@ class UnifiedRuntimeProcessingControllerTest {
void loadsDriverEventsViaRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService))
UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService))
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build();
@ -98,7 +108,8 @@ class UnifiedRuntimeProcessingControllerTest {
void loadsDriverTimelineViaRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService))
UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService))
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build();
@ -162,11 +173,286 @@ class UnifiedRuntimeProcessingControllerTest {
.andExpect(jsonPath("$.vehicleUsageIntervals[0].intervalId").value("CVU-1"));
}
@Test
void loadsDriverDerivedProjectionsViaRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class);
UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService))
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build();
UUID sessionId = UUID.randomUUID();
UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSession(
sessionId,
"12:123",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
true,
0
);
TachographEsperDriverProcessingResultDto projection = new TachographEsperDriverProcessingResultDto(
sessionId,
"12:123",
"UNIFIED_EVENT_STREAM",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
0,
0,
1,
0,
1,
0,
0,
0,
0,
0,
0,
0,
0,
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
List.of("runtime derived")
);
when(derivedProjectionService.loadDriverDerivedProjections(any()))
.thenReturn(new UnifiedRuntimeDerivedProjectionResultDto(
request,
2,
1,
1,
3,
List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")),
projection,
List.of("runtime derived")
));
mockMvc.perform(post("/api/eventhub/runtime-processing/driver-derived-projections")
.contentType("application/json")
.content("""
{
"sessionId": "%s",
"sourceFamilies": ["TACHOGRAPH_FILE_SESSION"],
"driverKey": "12:123",
"occurredFrom": "2026-05-01T08:00:00Z",
"occurredTo": "2026-05-01T10:00:00Z",
"expandVehicleEvents": true,
"significantDrivingMinutes": 3,
"minimumRestPeriodMinutes": 720
}
""".formatted(sessionId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.mergedEventCount").value(3))
.andExpect(jsonPath("$.projection.driverKey").value("12:123"))
.andExpect(jsonPath("$.projection.drivingInterruptionIntervalCount").value(1))
.andExpect(jsonPath("$.projection.dailyWeeklyRestCandidateIntervalCount").value(1));
}
@Test
void listsRuntimeEventProcessingProfilesViaRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class);
UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class);
RuntimeEventProcessingService runtimeEventProcessingService = org.mockito.Mockito.mock(RuntimeEventProcessingService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(
eventAssemblyService,
timelineService,
derivedProjectionService,
null,
runtimeEventProcessingService
))
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build();
when(runtimeEventProcessingService.listProfiles())
.thenReturn(List.of(new RuntimeEventProcessingProfileDescriptorDto(
"tachograph-driver-esper-v1",
"Tachograph Driver Esper Processing",
"Runs tachograph driver Esper processing over runtime event scopes.",
RuntimeEventPartitioningStrategy.DRIVER,
List.of(RuntimeEventPartitioningStrategy.DRIVER),
Set.of(),
Set.of("significantDrivingMinutes", "minimumRestPeriodMinutes")
)));
mockMvc.perform(get("/api/eventhub/runtime-processing/event-processing/profiles"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].profileKey").value("tachograph-driver-esper-v1"))
.andExpect(jsonPath("$[0].displayName").value("Tachograph Driver Esper Processing"))
.andExpect(jsonPath("$[0].defaultPartitioningStrategy").value("DRIVER"))
.andExpect(jsonPath("$[0].supportedPartitioningStrategies[0]").value("DRIVER"))
.andExpect(jsonPath("$[0].optionalParameters[0]").exists());
}
@Test
void runsGenericEventProcessingProfileViaRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class);
UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class);
RuntimeEventProcessingService runtimeEventProcessingService = org.mockito.Mockito.mock(RuntimeEventProcessingService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(
eventAssemblyService,
timelineService,
derivedProjectionService,
null,
runtimeEventProcessingService
))
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build();
UUID sessionId = UUID.randomUUID();
UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSession(
sessionId,
"12:123",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
true,
0
);
when(runtimeEventProcessingService.process(any()))
.thenReturn(new RuntimeEventProcessingResultDto(
"tachograph-driver-esper-v1",
RuntimeEventPartitioningStrategy.DRIVER,
request,
3,
1,
1,
List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")),
Map.of(),
List.of("generic profile"),
List.of()
));
mockMvc.perform(post("/api/eventhub/runtime-processing/event-processing")
.contentType("application/json")
.content("""
{
"profileKey": "tachograph-driver-esper-v1",
"scope": {
"sessionId": "%s",
"sourceFamilies": ["TACHOGRAPH_FILE_SESSION"],
"driverKey": "12:123",
"occurredFrom": "2026-05-01T08:00:00Z",
"occurredTo": "2026-05-01T10:00:00Z"
},
"partitioning": {
"strategy": "DRIVER",
"includeAllPartitions": false
},
"parameters": {
"significantDrivingMinutes": 3,
"minimumRestPeriodMinutes": 720
}
}
""".formatted(sessionId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.profileKey").value("tachograph-driver-esper-v1"))
.andExpect(jsonPath("$.partitioningStrategy").value("DRIVER"))
.andExpect(jsonPath("$.inputEventCount").value(3))
.andExpect(jsonPath("$.discoveredVehicles[0].vin").value("VIN-1"));
}
@Test
void compatibilityTachographEndpointDelegatesThroughGenericProfileRuntimeApi() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class);
UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class);
RuntimeEventProcessingService runtimeEventProcessingService = org.mockito.Mockito.mock(RuntimeEventProcessingService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(
eventAssemblyService,
timelineService,
derivedProjectionService,
null,
runtimeEventProcessingService
))
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build();
UUID sessionId = UUID.randomUUID();
UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSession(
sessionId,
"12:123",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
true,
0
);
UnifiedRuntimeDerivedProjectionResultDto driverResult = new UnifiedRuntimeDerivedProjectionResultDto(
request,
2,
1,
3,
5,
List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")),
null,
List.of("processed through generic profile")
);
when(runtimeEventProcessingService.process(any()))
.thenReturn(new RuntimeEventProcessingResultDto(
"tachograph-driver-esper-v1",
RuntimeEventPartitioningStrategy.DRIVER,
request,
5,
1,
1,
List.of(new UnifiedDiscoveredVehicleRef("VEH-1", "VIN-1", "12", "REG-1")),
Map.of("12:123", new RuntimeEventProcessingPartitionResultDto(
"DRIVER",
"12:123",
"UnifiedRuntimeDerivedProjectionResultDto",
driverResult,
Map.of("mergedEventCount", 5)
)),
List.of("generic adapter"),
List.of()
));
mockMvc.perform(post("/api/eventhub/runtime-processing/tachograph/esper-processing")
.contentType("application/json")
.content("""
{
"sessionId": "%s",
"sourceFamilies": ["TACHOGRAPH_FILE_SESSION"],
"driverKey": "12:123",
"occurredFrom": "2026-05-01T08:00:00Z",
"occurredTo": "2026-05-01T10:00:00Z",
"expandVehicleEvents": true,
"significantDrivingMinutes": 3,
"minimumRestPeriodMinutes": 720
}
""".formatted(sessionId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.inputEventCount").value(5))
.andExpect(jsonPath("$.selectedDriverCount").value(1))
.andExpect(jsonPath("$.driverResults['12:123'].mergedEventCount").value(5))
.andExpect(jsonPath("$.notes[0]").value("generic adapter"));
}
@Test
void returnsBadRequestForInvalidRuntimeRequest() throws Exception {
UnifiedRuntimeEventAssemblyService eventAssemblyService = org.mockito.Mockito.mock(UnifiedRuntimeEventAssemblyService.class);
UnifiedRuntimeDriverTimelineService timelineService = org.mockito.Mockito.mock(UnifiedRuntimeDriverTimelineService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService))
UnifiedRuntimeDerivedProjectionService derivedProjectionService = org.mockito.Mockito.mock(UnifiedRuntimeDerivedProjectionService.class);
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new UnifiedRuntimeProcessingController(eventAssemblyService, timelineService, derivedProjectionService))
.setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper))
.setControllerAdvice(new UnifiedRuntimeProcessingExceptionHandler())
.build();

View File

@ -0,0 +1,95 @@
package at.procon.eventhub.processing.eventprocessing;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfile;
import at.procon.eventhub.processing.eventprocessing.profile.RuntimeEventProcessingProfileRegistry;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class RuntimeEventProcessingServiceTest {
@Test
void processesThroughSelectedProfileAndListsProfiles() {
RuntimeEventProcessingService service = new RuntimeEventProcessingService(
new RuntimeEventProcessingProfileRegistry(List.of(new EchoProfile()))
);
UUID sessionId = UUID.randomUUID();
RuntimeEventProcessingApiRequest request = new RuntimeEventProcessingApiRequest(
"echo-profile-v1",
new UnifiedRuntimeProcessingApiRequest(
sessionId,
List.of(),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
"12:DRIVER-1",
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-01T01:00:00Z"),
true,
0,
null,
null
),
new RuntimeEventPartitioningApiRequest(RuntimeEventPartitioningStrategy.DRIVER, null, false, null, false, null, false, null, null),
Map.of()
);
RuntimeEventProcessingResultDto result = service.process(request);
assertThat(result.profileKey()).isEqualTo("echo-profile-v1");
assertThat(result.partitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER);
var profiles = service.listProfiles();
assertThat(profiles).hasSize(1);
assertThat(profiles.get(0).profileKey()).isEqualTo("echo-profile-v1");
}
private static final class EchoProfile implements RuntimeEventProcessingProfile {
@Override
public String profileKey() {
return "echo-profile-v1";
}
@Override
public RuntimeEventPartitioningStrategy defaultPartitioningStrategy() {
return RuntimeEventPartitioningStrategy.DRIVER;
}
@Override
public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) {
UnifiedRuntimeProcessingRequest runtimeRequest = request.scope().toRuntimeRequest();
return new RuntimeEventProcessingResultDto(
profileKey(),
defaultPartitioningStrategy(),
runtimeRequest,
0,
0,
0,
List.of(),
Map.of(),
List.of("echo"),
List.of()
);
}
}
}

View File

@ -0,0 +1,68 @@
package at.procon.eventhub.processing.eventprocessing.profile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingResultDto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
class RuntimeEventProcessingProfileRegistryTest {
@Test
void exposesProfileDescriptorsForDiscovery() {
RuntimeEventProcessingProfileRegistry registry = new RuntimeEventProcessingProfileRegistry(List.of(new TestProfile()));
var descriptors = registry.profileDescriptors();
assertThat(descriptors).hasSize(1);
assertThat(descriptors.get(0).profileKey()).isEqualTo("test-profile-v1");
assertThat(descriptors.get(0).displayName()).isEqualTo("Test Profile");
assertThat(descriptors.get(0).defaultPartitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER);
assertThat(descriptors.get(0).supportedPartitioningStrategies()).containsExactly(RuntimeEventPartitioningStrategy.DRIVER);
assertThat(descriptors.get(0).optionalParameters()).containsExactly("thresholdMinutes");
}
@Test
void rejectsDuplicateProfileKeys() {
assertThatThrownBy(() -> new RuntimeEventProcessingProfileRegistry(List.of(new TestProfile(), new TestProfile())))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Duplicate runtime event processing profileKey");
}
private static final class TestProfile implements RuntimeEventProcessingProfile {
@Override
public String profileKey() {
return "test-profile-v1";
}
@Override
public String displayName() {
return "Test Profile";
}
@Override
public String description() {
return "Test profile descriptor.";
}
@Override
public RuntimeEventPartitioningStrategy defaultPartitioningStrategy() {
return RuntimeEventPartitioningStrategy.DRIVER;
}
@Override
public Set<String> optionalParameters() {
return Set.of("thresholdMinutes");
}
@Override
public RuntimeEventProcessingResultDto process(RuntimeEventProcessingApiRequest request) {
throw new UnsupportedOperationException("Not needed by this test");
}
}
}

View File

@ -0,0 +1,149 @@
package at.procon.eventhub.processing.eventprocessing.profile;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.dto.UnifiedRuntimeTachographEsperScopeResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.service.UnifiedRuntimeTachographEsperScopeProcessingService;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class TachographDriverEsperRuntimeEventProcessingProfileTest {
@Test
void exposesDiscoveryMetadata() {
TachographDriverEsperRuntimeEventProcessingProfile profile = new TachographDriverEsperRuntimeEventProcessingProfile(
org.mockito.Mockito.mock(UnifiedRuntimeTachographEsperScopeProcessingService.class)
);
assertThat(profile.profileKey()).isEqualTo("tachograph-driver-esper-v1");
assertThat(profile.displayName()).isEqualTo("Tachograph Driver Esper Processing");
assertThat(profile.defaultPartitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER);
assertThat(profile.supportedPartitioningStrategies()).containsExactly(RuntimeEventPartitioningStrategy.DRIVER);
assertThat(profile.optionalParameters()).containsExactlyInAnyOrder("significantDrivingMinutes", "minimumRestPeriodMinutes", "attachVehicleOnlyEvents", "vehicleEvidencePaddingMinutes");
}
@Test
void delegatesToTachographScopeServiceAndMapsPartitionResults() {
UnifiedRuntimeTachographEsperScopeProcessingService scopeService = org.mockito.Mockito.mock(UnifiedRuntimeTachographEsperScopeProcessingService.class);
TachographDriverEsperRuntimeEventProcessingProfile profile = new TachographDriverEsperRuntimeEventProcessingProfile(scopeService);
UUID sessionId = UUID.randomUUID();
UnifiedRuntimeProcessingApiRequest scope = new UnifiedRuntimeProcessingApiRequest(
sessionId,
List.of(),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
null,
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-31T23:59:59Z"),
true,
15,
null,
null
);
RuntimeEventProcessingApiRequest request = new RuntimeEventProcessingApiRequest(
TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY,
scope,
new RuntimeEventPartitioningApiRequest(
RuntimeEventPartitioningStrategy.DRIVER,
Set.of("12:DRIVER-1"),
false,
Set.of(),
false,
null,
null,
null,
null
),
Map.of(
"significantDrivingMinutes", 5,
"minimumRestPeriodMinutes", "600",
"vehicleEvidencePaddingMinutes", 20,
"attachVehicleOnlyEvents", true
)
);
UnifiedRuntimeProcessingRequest processedRequest = new UnifiedRuntimeProcessingRequest(
sessionId,
List.of(sessionId),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
"12:DRIVER-1",
Set.of("12:DRIVER-1"),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-31T23:59:59Z"),
true,
15
);
UnifiedRuntimeDerivedProjectionResultDto driverResult = new UnifiedRuntimeDerivedProjectionResultDto(
processedRequest,
2,
1,
3,
5,
List.of(),
null,
List.of("driver processed")
);
when(scopeService.processScope(any()))
.thenReturn(new UnifiedRuntimeTachographEsperScopeResultDto(
processedRequest,
5,
1,
1,
List.of(),
Map.of("12:DRIVER-1", driverResult),
List.of("scope processed"),
List.of()
));
var result = profile.process(request);
assertThat(result.profileKey()).isEqualTo(TachographDriverEsperRuntimeEventProcessingProfile.PROFILE_KEY);
assertThat(result.partitioningStrategy()).isEqualTo(RuntimeEventPartitioningStrategy.DRIVER);
assertThat(result.partitionResults()).containsOnlyKeys("12:DRIVER-1");
assertThat(result.partitionResults().get("12:DRIVER-1").partitionType()).isEqualTo("DRIVER");
assertThat(result.partitionResults().get("12:DRIVER-1").result()).isSameAs(driverResult);
ArgumentCaptor<UnifiedRuntimeProcessingApiRequest> captor = ArgumentCaptor.forClass(UnifiedRuntimeProcessingApiRequest.class);
verify(scopeService).processScope(captor.capture());
UnifiedRuntimeProcessingApiRequest delegated = captor.getValue();
assertThat(delegated.driverKeys()).containsExactly("12:DRIVER-1");
assertThat(delegated.significantDrivingMinutes()).isEqualTo(5);
assertThat(delegated.minimumRestPeriodMinutes()).isEqualTo(600);
assertThat(delegated.vehicleExpansionPaddingMinutes()).isEqualTo(20);
assertThat(delegated.expandVehicleEvents()).isTrue();
}
}

View File

@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@ -89,14 +90,141 @@ class UnifiedRuntimeProcessingRequestTest {
assertThat(request.eventBackend()).isEqualTo(UnifiedRuntimeEventBackend.SOURCE_DB);
}
@Test
void canBuildMultiFileSessionRuntimeRequest() {
UUID firstSessionId = UUID.randomUUID();
UUID secondSessionId = UUID.randomUUID();
UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographFileSessions(
List.of(firstSessionId, secondSessionId),
"12:123",
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-03T00:00:00Z"),
true,
10
);
assertThat(request.sessionId()).isEqualTo(firstSessionId);
assertThat(request.sessionIds()).containsExactly(firstSessionId, secondSessionId);
assertThat(request.compositeSessionId()).isNull();
}
@Test
void canBuildCompositeFileSessionRuntimeRequest() {
UUID compositeSessionId = UUID.randomUUID();
UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographCompositeSession(
compositeSessionId,
"12:123",
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-03T00:00:00Z"),
true,
10
);
assertThat(request.sessionId()).isNull();
assertThat(request.sessionIds()).isEmpty();
assertThat(request.compositeSessionId()).isEqualTo(compositeSessionId);
}
@Test
void canBuildMultiDriverFileSessionRuntimeScopeRequest() {
UUID sessionId = UUID.randomUUID();
UnifiedRuntimeProcessingRequest request = new UnifiedRuntimeProcessingRequest(
sessionId,
List.of(),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
UnifiedRuntimeEventBackend.SOURCE_DB,
null,
Set.of("12:123", "12:456"),
false,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
true,
10
);
assertThat(request.driverKey()).isNull();
assertThat(request.driverKeys()).containsExactlyInAnyOrder("12:123", "12:456");
}
@Test
void canBuildIncludeAllDriversRuntimeScopeRequest() {
UUID sessionId = UUID.randomUUID();
UnifiedRuntimeProcessingRequest request = new UnifiedRuntimeProcessingRequest(
sessionId,
List.of(),
null,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
UnifiedRuntimeEventBackend.SOURCE_DB,
null,
Set.of(),
true,
Set.of(),
false,
null,
null,
null,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
true,
10
);
assertThat(request.includeAllDrivers()).isTrue();
assertThat(request.driverKey()).isNull();
}
@Test
void rejectsAmbiguousExplicitAndCompositeSessionSelectors() {
UUID sessionId = UUID.randomUUID();
UUID compositeSessionId = UUID.randomUUID();
assertThatThrownBy(() -> new UnifiedRuntimeProcessingRequest(
sessionId,
List.of(),
compositeSessionId,
null,
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
UnifiedRuntimeEventBackend.SOURCE_DB,
"12:123",
Set.of(),
false,
Set.of(),
false,
null,
null,
null,
null,
null,
true,
0
)).isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Use either compositeSessionId");
}
@Test
void rejectsRequestWithoutDriverSelector() {
assertThatThrownBy(() -> new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null,
"default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB),
UnifiedRuntimeEventBackend.SOURCE_DB,
null,
Set.of(),
false,
Set.of(),
false,
null,
null,
null,

View File

@ -0,0 +1,205 @@
package at.procon.eventhub.processing.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.dto.DriverRefDto;
import at.procon.eventhub.dto.EventDomain;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventScopeClassifier;
import at.procon.eventhub.processing.model.RuntimeDriverVehicleEvidenceAttachmentResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class RuntimeDriverVehicleEvidenceAttachmentServiceTest {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final RuntimeDriverVehicleEvidenceAttachmentService service = new RuntimeDriverVehicleEvidenceAttachmentService(
new UnifiedEventTimelineReconstructor(),
new RuntimeEventScopeClassifier()
);
@Test
void attachesVehicleOnlyEvidenceInsideDriverVehicleUsageInterval() {
List<EventHubEventDto> driverEvents = List.of(
cardEvent("card-1-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T08:00:00Z"),
cardEvent("card-1-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T18:00:00Z")
);
EventHubEventDto vehicleEvidence = vehicleOnlyEvent("pos-inside", "VIN-1", "AT:W-1", "2026-05-01T12:00:00Z");
EventHubEventDto outside = vehicleOnlyEvent("pos-outside", "VIN-1", "AT:W-1", "2026-05-01T20:00:00Z");
EventHubEventDto wrongVehicle = vehicleOnlyEvent("pos-wrong", "VIN-2", "AT:W-2", "2026-05-01T12:00:00Z");
RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence(
"DRIVER-1",
driverEvents,
List.of(driverEvents.get(0), driverEvents.get(1), vehicleEvidence, outside, wrongVehicle),
true,
0
);
assertThat(result.vehicleUsageIntervalCount()).isEqualTo(1);
assertThat(result.candidateVehicleEvidenceEventCount()).isEqualTo(3);
assertThat(result.attachedVehicleEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("pos-inside");
assertThat(result.ignoredVehicleEvidenceEventCount()).isEqualTo(2);
assertThat(result.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("card-1-in", "pos-inside", "card-1-out");
}
@Test
void usesConfiguredPaddingWhenAttachingVehicleEvidence() {
List<EventHubEventDto> driverEvents = List.of(
cardEvent("card-1-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T08:00:00Z"),
cardEvent("card-1-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T18:00:00Z")
);
EventHubEventDto justAfter = vehicleOnlyEvent("pos-after", "VIN-1", "AT:W-1", "2026-05-01T18:10:00Z");
RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence(
"DRIVER-1",
driverEvents,
List.of(driverEvents.get(0), driverEvents.get(1), justAfter),
true,
15
);
assertThat(result.attachedVehicleEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("pos-after");
}
@Test
void doesNotAttachVehicleEvidenceWhenDisabled() {
List<EventHubEventDto> driverEvents = List.of(
cardEvent("card-1-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T08:00:00Z"),
cardEvent("card-1-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T18:00:00Z")
);
EventHubEventDto vehicleEvidence = vehicleOnlyEvent("pos-inside", "VIN-1", "AT:W-1", "2026-05-01T12:00:00Z");
RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence(
"DRIVER-1",
driverEvents,
List.of(driverEvents.get(0), driverEvents.get(1), vehicleEvidence),
false,
0
);
assertThat(result.attachedVehicleEvidenceEvents()).isEmpty();
assertThat(result.mergedEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("card-1-in", "card-1-out");
}
@Test
void mergesMidnightVehicleUsageContinuationBeforeEvidenceAttachment() {
List<EventHubEventDto> driverEvents = List.of(
cardEvent("card-1-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T20:00:00Z"),
cardEvent("card-1-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-1", "VIN-1", "AT:W-1", "2026-05-01T23:59:59Z"),
cardEvent("card-2-in", EventType.CARD_INSERTED, "DRIVER-1", "interval-2", "VIN-1", "AT:W-1", "2026-05-02T00:00:00Z"),
cardEvent("card-2-out", EventType.CARD_WITHDRAWN, "DRIVER-1", "interval-2", "VIN-1", "AT:W-1", "2026-05-02T08:00:00Z")
);
EventHubEventDto midnightEvidence = vehicleOnlyEvent("pos-midnight", "VIN-1", "AT:W-1", "2026-05-02T00:00:00Z");
RuntimeDriverVehicleEvidenceAttachmentResult result = service.attachVehicleEvidence(
"DRIVER-1",
driverEvents,
List.of(driverEvents.get(0), driverEvents.get(1), driverEvents.get(2), driverEvents.get(3), midnightEvidence),
true,
0
);
assertThat(result.vehicleUsageIntervalCount()).isEqualTo(1);
assertThat(result.attachedVehicleEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("pos-midnight");
}
private EventHubEventDto cardEvent(
String externalId,
EventType eventType,
String driverKey,
String intervalId,
String vehicleKey,
String registrationKey,
String occurredAt
) {
EventLifecycle lifecycle = eventType == EventType.CARD_INSERTED ? EventLifecycle.INSERT : EventLifecycle.WITHDRAW;
return new EventHubEventDto(
UUID.randomUUID(),
externalId,
new DriverRefDto(driverKey, null),
vehicleRef(vehicleKey, registrationKey),
OffsetDateTime.parse(occurredAt),
null,
OffsetDateTime.parse(occurredAt),
EventDomain.DRIVER_CARD,
eventType,
lifecycle,
null,
null,
null,
null,
raw(driverKey, intervalId, vehicleKey, registrationKey),
false,
null
);
}
private EventHubEventDto vehicleOnlyEvent(
String externalId,
String vehicleKey,
String registrationKey,
String occurredAt
) {
return new EventHubEventDto(
UUID.randomUUID(),
externalId,
null,
vehicleRef(vehicleKey, registrationKey),
OffsetDateTime.parse(occurredAt),
null,
OffsetDateTime.parse(occurredAt),
EventDomain.POSITION,
EventType.POSITION_RECORDED,
EventLifecycle.SNAPSHOT,
null,
null,
null,
null,
raw(null, null, vehicleKey, registrationKey),
false,
null
);
}
private VehicleRefDto vehicleRef(String vehicleKey, String registrationKey) {
String[] registrationParts = registrationKey.split(":", 2);
return new VehicleRefDto(
"VIN:" + vehicleKey,
vehicleKey,
"VR:" + registrationKey,
new VehicleRegistrationRefDto(registrationParts[0], registrationParts[1])
);
}
private JsonNode raw(String driverKey, String intervalId, String vehicleKey, String registrationKey) {
ObjectNode root = OBJECT_MAPPER.createObjectNode();
ObjectNode raw = root.putObject("raw");
if (driverKey != null) {
raw.put("driverKey", driverKey);
}
if (intervalId != null) {
raw.put("intervalId", intervalId);
raw.put("sourceRowId", intervalId);
}
raw.put("vehicleKey", vehicleKey);
raw.put("registrationKey", registrationKey);
raw.put("sourceKind", "TEST");
return root;
}
}

View File

@ -5,7 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.service.EventAcquisitionRecordKeyService;
import at.procon.eventhub.service.EventDetailsFactory;
import at.procon.eventhub.service.EventHubEventSorter;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
@ -19,6 +21,8 @@ import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import at.procon.eventhub.tachographfilesession.service.DriverKeyFactory;
import at.procon.eventhub.tachographfilesession.service.DriverTimelineBuilder;
import at.procon.eventhub.tachographfilesession.model.TachographCompositeSession;
import at.procon.eventhub.tachographfilesession.service.InMemoryTachographCompositeSessionRepository;
import at.procon.eventhub.tachographfilesession.service.InMemoryTachographFileSessionRepository;
import at.procon.eventhub.tachographfilesession.service.IntervalBackedDriverTimelineEventBuilder;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionRepository;
@ -39,6 +43,7 @@ class TachographFileSessionRuntimeEventLoaderTest {
void loadsDriverAndVehicleEventsFromFileSessionRuntimePath() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
InMemoryTachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository();
IntervalBackedDriverTimelineEventBuilder eventBuilder = new IntervalBackedDriverTimelineEventBuilder(
new DriverTimelineBuilder(),
new DriverKeyFactory(),
@ -47,7 +52,10 @@ class TachographFileSessionRuntimeEventLoaderTest {
);
TachographFileSessionRuntimeEventLoader loader = new TachographFileSessionRuntimeEventLoader(
new UnifiedDriverEventSourceService(List.of(new TachographFileSessionUnifiedDriverEventSource(repository, eventBuilder))),
new UnifiedVehicleEventSourceService(List.of(new TachographFileSessionUnifiedVehicleEventSource(repository, eventBuilder)))
new UnifiedVehicleEventSourceService(List.of(new TachographFileSessionUnifiedVehicleEventSource(repository, eventBuilder))),
compositeRepository,
new EventAcquisitionRecordKeyService(),
new EventHubEventSorter()
);
DriverExtractionSession driver = driver();
@ -68,6 +76,56 @@ class TachographFileSessionRuntimeEventLoaderTest {
assertThat(loader.loadVehicleEvents(request, new UnifiedDiscoveredVehicleRef("VIN-1", "VIN-1", "12", "REG-1"))).hasSize(5);
}
@Test
void loadsDriverAndVehicleEventsFromCompositeSessionRuntimePath() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
InMemoryTachographCompositeSessionRepository compositeRepository = new InMemoryTachographCompositeSessionRepository();
IntervalBackedDriverTimelineEventBuilder eventBuilder = new IntervalBackedDriverTimelineEventBuilder(
new DriverTimelineBuilder(),
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
);
TachographFileSessionRuntimeEventLoader loader = new TachographFileSessionRuntimeEventLoader(
new UnifiedDriverEventSourceService(List.of(new TachographFileSessionUnifiedDriverEventSource(repository, eventBuilder))),
new UnifiedVehicleEventSourceService(List.of(new TachographFileSessionUnifiedVehicleEventSource(repository, eventBuilder))),
compositeRepository,
new EventAcquisitionRecordKeyService(),
new EventHubEventSorter()
);
DriverExtractionSession firstDriverSession = driver();
DriverExtractionSession secondDriverSession = driverOnSecondDay();
TachographFileSession firstSession = session(firstDriverSession);
TachographFileSession secondSession = session(secondDriverSession);
repository.save(firstSession);
repository.save(secondSession);
UUID compositeSessionId = UUID.randomUUID();
compositeRepository.save(new TachographCompositeSession(
compositeSessionId,
"default",
"runtime-composite",
List.of(firstSession.sessionId(), secondSession.sessionId()),
Instant.now()
));
UnifiedRuntimeProcessingRequest request = UnifiedRuntimeProcessingRequest.forTachographCompositeSession(
compositeSessionId,
firstDriverSession.driverKey(),
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-03T00:00:00Z"),
true,
0
);
assertThat(loader.loadDriverEvents(request)).hasSize(10);
assertThat(loader.loadVehicleEvents(request, new UnifiedDiscoveredVehicleRef("VIN-1", "VIN-1", "12", "REG-1"))).hasSize(10);
}
private DriverExtractionSession driver() {
return new DriverExtractionSession(
"12:123",
@ -125,6 +183,66 @@ class TachographFileSessionRuntimeEventLoaderTest {
);
}
private DriverExtractionSession driverOnSecondDay() {
return new DriverExtractionSession(
"12:123",
new ExtractedDriver("12:123", "DRV:12:123", "Doe", "Jane", null, null, null, null, null),
new ExtractedDriverCard("CARD:12:123", "12", "123", "123", null, null, null, null),
List.of(new ExtractedVehicleRegistration("12:REG-1", "VR:12:REG-1", "12", "REG-1")),
List.of(new ExtractedVehicle("VIN-1", "VIN:VIN-1", "VIN-1")),
List.of(new ExtractedCardVehicleUsageInterval(
"CVU-2",
OffsetDateTime.parse("2026-05-02T08:00:00Z"),
OffsetDateTime.parse("2026-05-02T10:00:00Z"),
200L,
300L,
"12:REG-1",
"VIN-1",
"vu-2"
)),
List.of(new ExtractedCardActivityInterval(
"ACT-2",
OffsetDateTime.parse("2026-05-02T08:30:00Z"),
OffsetDateTime.parse("2026-05-02T09:00:00Z"),
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"a2"
)),
List.of(new ExtractedSupportEvent(
"SUP-2",
"12:123",
OffsetDateTime.parse("2026-05-02T08:45:00Z"),
"POSITION",
"POSITION_RECORDED",
"SNAPSHOT",
"DRIVER",
"12:REG-1",
"VIN-1",
null,
null,
null,
null,
null,
BigDecimal.valueOf(48.2082),
BigDecimal.valueOf(16.3738),
"AUTHENTIC",
250L,
null,
null,
null,
"raw-path-2"
)),
List.of()
);
}
private TachographFileSession session(DriverExtractionSession driver) {
return new TachographFileSession(
UUID.randomUUID(),

View File

@ -41,11 +41,17 @@ class UnifiedRuntimeDriverTimelineServiceTest {
ResolvedDriverTimeline timeline = service.loadDriverTimeline(
new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null,
"default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB),
UnifiedRuntimeEventBackend.SOURCE_DB,
null,
Set.of(),
false,
Set.of(),
false,
"DRIVER:42",
null,
null,

View File

@ -34,11 +34,17 @@ class UnifiedRuntimeEventAssemblyServiceTest {
UnifiedRuntimeEventBundle bundle = service.assembleDriverScopedEvents(
new UnifiedRuntimeProcessingRequest(
null,
List.of(),
null,
"default",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_DB),
UnifiedRuntimeEventBackend.SOURCE_DB,
null,
Set.of(),
false,
Set.of(),
false,
"DRIVER:42",
null,
null,

View File

@ -0,0 +1,173 @@
package at.procon.eventhub.tachographfilesession.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.service.EventDetailsFactory;
import at.procon.eventhub.tachographfilesession.dto.TachographEsperDriverProcessingResultDto;
import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class TachographEsperProcessingCoreParityTest {
@Test
void fileSessionAndEventInputUseSameCoreAndProduceEquivalentProjectionSections() {
EventHubProperties properties = new EventHubProperties();
DriverTimelineBuilder driverTimelineBuilder = new DriverTimelineBuilder();
IntervalBackedDriverTimelineEventBuilder intervalEventBuilder = new IntervalBackedDriverTimelineEventBuilder(
driverTimelineBuilder,
new DriverKeyFactory(),
new VehicleKeyFactory(),
new EventDetailsFactory(new ObjectMapper())
);
RawSourceDriverTimelineEventBuilder rawSourceEventBuilder = new RawSourceDriverTimelineEventBuilder(intervalEventBuilder);
DriverTimelineReusableProjectionBuilder projectionBuilder = new DriverTimelineReusableProjectionBuilder(
driverTimelineBuilder,
rawSourceEventBuilder,
properties
);
TachographEsperProcessingCore core = new TachographEsperProcessingCore(
driverTimelineBuilder,
projectionBuilder,
properties
);
DriverExtractionSession driver = driver();
TachographFileSession session = session(driver);
ResolvedDriverTimeline timeline = driverTimelineBuilder.build(session, driver);
List<EventHubEventDto> rawEvents = rawSourceEventBuilder.buildRawEventBundle(session, driver).allEvents();
TachographEsperDriverProcessingResultDto fileSessionResult = core.process(TachographEsperProcessingInput.fromFileSession(
session,
driver,
timeline,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-03T00:00:00Z"),
3,
420,
List.of("file-session-adapter")
));
TachographEsperDriverProcessingResultDto eventInputResult = core.process(TachographEsperProcessingInput.fromEvents(
session.sessionId(),
driver.driverKey(),
timeline,
rawEvents,
OffsetDateTime.parse("2026-05-01T00:00:00Z"),
OffsetDateTime.parse("2026-05-03T00:00:00Z"),
3,
420,
List.of("runtime-adapter")
));
assertThat(eventInputResult.activityIntervalCount()).isEqualTo(fileSessionResult.activityIntervalCount());
assertThat(eventInputResult.drivingIntervalCount()).isEqualTo(fileSessionResult.drivingIntervalCount());
assertThat(eventInputResult.vehicleUsageIntervalCount()).isEqualTo(fileSessionResult.vehicleUsageIntervalCount());
assertThat(eventInputResult.vuCardAbsentIntervalCount()).isEqualTo(fileSessionResult.vuCardAbsentIntervalCount());
assertThat(eventInputResult.dailyWeeklyRestCandidateIntervalCount())
.isEqualTo(fileSessionResult.dailyWeeklyRestCandidateIntervalCount());
assertThat(eventInputResult.dailyWeeklyRestCandidateCoverageIntervalCount())
.isEqualTo(fileSessionResult.dailyWeeklyRestCandidateCoverageIntervalCount());
assertThat(eventInputResult.potentialInVehicleOvernightStayIntervalCount())
.isEqualTo(fileSessionResult.potentialInVehicleOvernightStayIntervalCount());
assertThat(eventInputResult.potentialHomeOvernightStayIntervalCount())
.isEqualTo(fileSessionResult.potentialHomeOvernightStayIntervalCount());
assertThat(eventInputResult.notes()).contains("runtime-adapter");
}
private DriverExtractionSession driver() {
return new DriverExtractionSession(
"12:123",
null,
null,
List.of(),
List.of(),
List.of(
new ExtractedCardVehicleUsageInterval(
"CVU-1",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T23:59:59Z"),
100L,
200L,
"12:REG-1",
"VIN-1",
"vu-1"
),
new ExtractedCardVehicleUsageInterval(
"CVU-2",
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T10:00:00Z"),
200L,
260L,
"12:REG-1",
"VIN-1",
"vu-2"
)
),
List.of(
new ExtractedCardActivityInterval(
"ACT-1",
OffsetDateTime.parse("2026-05-01T08:30:00Z"),
OffsetDateTime.parse("2026-05-01T09:00:00Z"),
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"a"
),
new ExtractedCardActivityInterval(
"ACT-2",
OffsetDateTime.parse("2026-05-01T18:30:00Z"),
OffsetDateTime.parse("2026-05-01T18:45:00Z"),
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"b"
)
),
List.of(),
List.of()
);
}
private TachographFileSession session(DriverExtractionSession driver) {
return new TachographFileSession(
UUID.randomUUID(),
new TachographFileSessionMetadata(
"default",
"legalrequirements-drivercard",
"sample",
"sample.ddd",
"a",
2,
"42",
"b",
true,
null
),
Map.of(driver.driverKey(), driver),
new ExtractionStats(1, 2, 2, 1, 1, 0),
List.of(),
Instant.now(),
Instant.now().plus(4, ChronoUnit.HOURS)
);
}
}