Add rest candidate Esper projections

This commit is contained in:
trifonovt 2026-05-13 13:48:29 +02:00
parent eb4e04f144
commit 3b2f893246
14 changed files with 650 additions and 11 deletions

View File

@ -386,7 +386,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"occurredFrom\": \"{{occurredFrom}}\",\n \"occurredTo\": \"{{occurredTo}}\",\n \"significantDrivingMinutes\": 3\n}"
"raw": "{\n \"occurredFrom\": \"{{occurredFrom}}\",\n \"occurredTo\": \"{{occurredTo}}\",\n \"significantDrivingMinutes\": 3,\n \"minimumRestPeriodMinutes\": 720\n}"
},
"url": {
"raw": "{{baseUrl}}/api/eventhub/tachograph-file-sessions/{{sessionId}}/drivers/{{driverKey}}/processing/esper-events",

View File

@ -358,6 +358,7 @@ public class EventHubProperties {
public static class Processing {
private int operatingSplitIdleHours = 7;
private int significantDrivingMinutes = 3;
private int minimumRestPeriodMinutes = 720;
private int mergeGapSeconds = 0;
private int gapDetectionToleranceSeconds = 0;
@ -377,6 +378,14 @@ public class EventHubProperties {
this.significantDrivingMinutes = Math.max(1, significantDrivingMinutes);
}
public int getMinimumRestPeriodMinutes() {
return minimumRestPeriodMinutes;
}
public void setMinimumRestPeriodMinutes(int minimumRestPeriodMinutes) {
this.minimumRestPeriodMinutes = Math.max(1, minimumRestPeriodMinutes);
}
public int getMergeGapSeconds() {
return mergeGapSeconds;
}

View File

@ -2,6 +2,7 @@ package at.procon.eventhub.tachographfilesession.dto;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import java.time.OffsetDateTime;
@ -19,11 +20,15 @@ public record TachographEsperDriverProcessingResultDto(
int activityIntervalCount,
int drivingIntervalCount,
int drivingInterruptionIntervalCount,
int drivingInterruptionVehicleChangeIntervalCount,
int potentialHomeOvernightStayIntervalCount,
int vehicleUsageIntervalCount,
int vuCardAbsentIntervalCount,
List<TachographEsperActivityIntervalEvent> activityIntervals,
List<TachographEsperActivityIntervalEvent> drivingIntervals,
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals,
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals,
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals,
List<TachographEsperVehicleUsageIntervalEvent> vehicleUsageIntervals,
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals,
List<String> notes

View File

@ -5,9 +5,13 @@ import java.time.OffsetDateTime;
public record TachographEsperEventsProcessingRequest(
OffsetDateTime occurredFrom,
OffsetDateTime occurredTo,
Integer significantDrivingMinutes
Integer significantDrivingMinutes,
Integer minimumRestPeriodMinutes
) {
public TachographEsperEventsProcessingRequest {
significantDrivingMinutes = significantDrivingMinutes == null ? null : Math.max(1, significantDrivingMinutes);
minimumRestPeriodMinutes = minimumRestPeriodMinutes == null
? null
: Math.max(1, minimumRestPeriodMinutes);
}
}

View File

@ -11,6 +11,8 @@ public record TachographEsperDrivingInterruptionIntervalEvent(
long durationSeconds,
String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId,
String previousRegistrationKey,
String nextRegistrationKey,
String previousVehicleKey,
String nextVehicleKey
) {

View File

@ -0,0 +1,21 @@
package at.procon.eventhub.tachographfilesession.model;
import java.time.OffsetDateTime;
import java.util.UUID;
public record TachographEsperPotentialHomeOvernightStayIntervalEvent(
UUID sessionId,
String driverKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
long durationSeconds,
long unknownDurationSeconds,
double unknownCoveragePercent,
String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId,
String previousRegistrationKey,
String nextRegistrationKey,
String previousVehicleKey,
String nextVehicleKey
) {
}

View File

@ -20,6 +20,7 @@ import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
@ -52,6 +53,10 @@ public class DriverTimelineBuilder {
loadResource("esper/tachograph-driving-interval-events.epl");
private static final String DRIVING_INTERRUPTION_INTERVAL_EVENTS_EPL_TEMPLATE =
loadResource("esper/tachograph-driving-interruption-interval-events.epl");
private static final String DRIVING_INTERRUPTION_VEHICLE_CHANGE_INTERVAL_EVENTS_EPL =
loadResource("esper/tachograph-driving-interruption-vehicle-change-interval-events.epl");
private static final String POTENTIAL_HOME_OVERNIGHT_STAY_INTERVAL_EVENTS_EPL_TEMPLATE =
loadResource("esper/tachograph-potential-home-overnight-stay-interval-events.epl");
private static final String VEHICLE_USAGE_INTERVAL_EVENTS_EPL =
loadResource("esper/tachograph-vehicle-usage-interval-events.epl");
private static final String VU_CARD_ABSENT_INTERVAL_EVENTS_EPL =
@ -176,6 +181,77 @@ public class DriverTimelineBuilder {
);
}
public List<TachographEsperDrivingInterruptionIntervalEvent> buildEsperDrivingInterruptionVehicleChangeIntervalEvents(
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals
) {
if (drivingInterruptionIntervals == null || drivingInterruptionIntervals.isEmpty()) {
return List.of();
}
List<TachographEsperDrivingInterruptionIntervalEvent> result = new ArrayList<>();
executeWithRuntime(
configuration -> configuration.getCommon().addEventType(
"TachographDrivingInterruptionIntervalInputEvent",
drivingInterruptionIntervalInputDefinition()
),
DRIVING_INTERRUPTION_VEHICLE_CHANGE_INTERVAL_EVENTS_EPL,
"drivingInterruptionVehicleChangeIntervals",
newData -> collectDrivingInterruptionIntervalEventsFromTimestamps(newData, result),
runtime -> {
for (TachographEsperDrivingInterruptionIntervalEvent interval : drivingInterruptionIntervals) {
runtime.getEventService().sendEventMap(
toDrivingInterruptionIntervalInputMap(interval),
"TachographDrivingInterruptionIntervalInputEvent"
);
}
}
);
return result;
}
public List<TachographEsperPotentialHomeOvernightStayIntervalEvent> buildEsperPotentialHomeOvernightStayIntervalEvents(
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals,
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals,
int minimumRestPeriodMinutes
) {
if (drivingInterruptionIntervals == null
|| drivingInterruptionIntervals.isEmpty()
|| vuCardAbsentIntervals == null
|| vuCardAbsentIntervals.isEmpty()) {
return List.of();
}
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> result = new ArrayList<>();
executeWithRuntime(
configuration -> {
configuration.getCommon().addEventType(
"TachographDrivingInterruptionIntervalInputEvent",
drivingInterruptionIntervalInputDefinition()
);
configuration.getCommon().addEventType(
"TachographVuCardAbsentIntervalInputEvent",
vuCardAbsentIntervalInputDefinition()
);
},
renderPotentialHomeOvernightStayIntervalEventsEpl(minimumRestPeriodMinutes),
"potentialHomeOvernightStayIntervals",
newData -> collectPotentialHomeOvernightStayIntervalEvents(newData, result),
runtime -> {
for (TachographEsperVuCardAbsentIntervalEvent interval : vuCardAbsentIntervals) {
runtime.getEventService().sendEventMap(
toVuCardAbsentIntervalInputMap(interval),
"TachographVuCardAbsentIntervalInputEvent"
);
}
for (TachographEsperDrivingInterruptionIntervalEvent interval : drivingInterruptionIntervals) {
runtime.getEventService().sendEventMap(
toDrivingInterruptionIntervalInputMap(interval),
"TachographDrivingInterruptionIntervalInputEvent"
);
}
}
);
return result;
}
public List<TachographEsperVuCardAbsentIntervalEvent> buildEsperVuCardAbsentIntervalEvents(
TachographFileSession session,
DriverExtractionSession driverSession
@ -602,6 +678,42 @@ public class DriverTimelineBuilder {
return definition;
}
private Map<String, Object> drivingInterruptionIntervalInputDefinition() {
Map<String, Object> definition = new LinkedHashMap<>();
definition.put("sessionId", UUID.class);
definition.put("driverKey", String.class);
definition.put("startedAt", OffsetDateTime.class);
definition.put("endedAt", OffsetDateTime.class);
definition.put("startedAtEpochSecond", long.class);
definition.put("endedAtEpochSecond", long.class);
definition.put("durationSeconds", long.class);
definition.put("previousDrivingSourceIntervalId", String.class);
definition.put("nextDrivingSourceIntervalId", String.class);
definition.put("previousRegistrationKey", String.class);
definition.put("nextRegistrationKey", String.class);
definition.put("previousVehicleKey", String.class);
definition.put("nextVehicleKey", String.class);
return definition;
}
private Map<String, Object> vuCardAbsentIntervalInputDefinition() {
Map<String, Object> definition = new LinkedHashMap<>();
definition.put("sessionId", UUID.class);
definition.put("driverKey", String.class);
definition.put("startedAt", OffsetDateTime.class);
definition.put("endedAt", OffsetDateTime.class);
definition.put("startedAtEpochSecond", long.class);
definition.put("endedAtEpochSecond", long.class);
definition.put("durationSeconds", long.class);
definition.put("previousUsageIntervalId", String.class);
definition.put("nextUsageIntervalId", String.class);
definition.put("previousRegistrationKey", String.class);
definition.put("nextRegistrationKey", String.class);
definition.put("previousVehicleKey", String.class);
definition.put("nextVehicleKey", String.class);
return definition;
}
private Map<String, Object> toActivityIntervalInputMap(
UUID sessionId,
String driverKey,
@ -663,6 +775,44 @@ public class DriverTimelineBuilder {
return event;
}
private Map<String, Object> toDrivingInterruptionIntervalInputMap(
TachographEsperDrivingInterruptionIntervalEvent interval
) {
Map<String, Object> event = new LinkedHashMap<>();
event.put("sessionId", interval.sessionId());
event.put("driverKey", interval.driverKey());
event.put("startedAt", interval.startedAt());
event.put("endedAt", interval.endedAt());
event.put("startedAtEpochSecond", interval.startedAt().toEpochSecond());
event.put("endedAtEpochSecond", interval.endedAt().toEpochSecond());
event.put("durationSeconds", interval.durationSeconds());
event.put("previousDrivingSourceIntervalId", interval.previousDrivingSourceIntervalId());
event.put("nextDrivingSourceIntervalId", interval.nextDrivingSourceIntervalId());
event.put("previousRegistrationKey", interval.previousRegistrationKey());
event.put("nextRegistrationKey", interval.nextRegistrationKey());
event.put("previousVehicleKey", interval.previousVehicleKey());
event.put("nextVehicleKey", interval.nextVehicleKey());
return event;
}
private Map<String, Object> toVuCardAbsentIntervalInputMap(TachographEsperVuCardAbsentIntervalEvent interval) {
Map<String, Object> event = new LinkedHashMap<>();
event.put("sessionId", interval.sessionId());
event.put("driverKey", interval.driverKey());
event.put("startedAt", interval.startedAt());
event.put("endedAt", interval.endedAt());
event.put("startedAtEpochSecond", interval.startedAt().toEpochSecond());
event.put("endedAtEpochSecond", interval.endedAt().toEpochSecond());
event.put("durationSeconds", interval.durationSeconds());
event.put("previousUsageIntervalId", interval.previousUsageIntervalId());
event.put("nextUsageIntervalId", interval.nextUsageIntervalId());
event.put("previousRegistrationKey", interval.previousRegistrationKey());
event.put("nextRegistrationKey", interval.nextRegistrationKey());
event.put("previousVehicleKey", interval.previousVehicleKey());
event.put("nextVehicleKey", interval.nextVehicleKey());
return event;
}
private String firstSourceIntervalId(ResolvedVehicleUsageInterval interval) {
return interval.sourceIntervalIds().isEmpty() ? interval.intervalId() : interval.sourceIntervalIds().get(0);
}
@ -746,6 +896,32 @@ public class DriverTimelineBuilder {
(Long) event.get("durationSeconds"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
(String) event.get("nextRegistrationKey"),
(String) event.get("previousVehicleKey"),
(String) event.get("nextVehicleKey")
));
}
}
private void collectDrivingInterruptionIntervalEventsFromTimestamps(
EventBean[] newData,
List<TachographEsperDrivingInterruptionIntervalEvent> target
) {
if (newData == null) {
return;
}
for (EventBean event : newData) {
target.add(new TachographEsperDrivingInterruptionIntervalEvent(
(UUID) event.get("sessionId"),
(String) event.get("driverKey"),
(OffsetDateTime) event.get("startedAt"),
(OffsetDateTime) event.get("endedAt"),
(Long) event.get("durationSeconds"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
(String) event.get("nextRegistrationKey"),
(String) event.get("previousVehicleKey"),
(String) event.get("nextVehicleKey")
));
@ -778,6 +954,32 @@ public class DriverTimelineBuilder {
}
}
private void collectPotentialHomeOvernightStayIntervalEvents(
EventBean[] newData,
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> target
) {
if (newData == null) {
return;
}
for (EventBean event : newData) {
target.add(new TachographEsperPotentialHomeOvernightStayIntervalEvent(
(UUID) event.get("sessionId"),
(String) event.get("driverKey"),
(OffsetDateTime) event.get("startedAt"),
(OffsetDateTime) event.get("endedAt"),
(Long) event.get("durationSeconds"),
(Long) event.get("unknownDurationSeconds"),
(Double) event.get("unknownCoveragePercent"),
(String) event.get("previousDrivingSourceIntervalId"),
(String) event.get("nextDrivingSourceIntervalId"),
(String) event.get("previousRegistrationKey"),
(String) event.get("nextRegistrationKey"),
(String) event.get("previousVehicleKey"),
(String) event.get("nextVehicleKey")
));
}
}
@SuppressWarnings("unchecked")
private List<String> castSourceIntervalIds(Object value) {
return value == null ? List.of() : List.copyOf((List<String>) value);
@ -799,4 +1001,12 @@ public class DriverTimelineBuilder {
Long.toString(thresholdSeconds)
);
}
private String renderPotentialHomeOvernightStayIntervalEventsEpl(int minimumRestPeriodMinutes) {
long thresholdSeconds = Math.max(1, minimumRestPeriodMinutes) * 60L;
return POTENTIAL_HOME_OVERNIGHT_STAY_INTERVAL_EVENTS_EPL_TEMPLATE.replace(
"${POTENTIAL_HOME_OVERNIGHT_STAY_THRESHOLD_SECONDS}",
Long.toString(thresholdSeconds)
);
}
}

View File

@ -15,6 +15,7 @@ import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import java.time.Duration;
@ -132,7 +133,7 @@ public class TachographFileSessionProcessingService {
TachographEsperEventsProcessingRequest request
) {
TachographEsperEventsProcessingRequest effectiveRequest = request == null
? new TachographEsperEventsProcessingRequest(null, null, null)
? new TachographEsperEventsProcessingRequest(null, null, null, null)
: request;
TachographFileSession session = repository.find(sessionId)
.orElseThrow(() -> new TachographFileSessionNotFoundException(sessionId));
@ -148,6 +149,7 @@ public class TachographFileSessionProcessingService {
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),
@ -159,24 +161,47 @@ public class TachographFileSessionProcessingService {
requestedFrom,
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals =
clipEsperDrivingInterruptionIntervalEvents(
List<TachographEsperDrivingInterruptionIntervalEvent> rawDrivingInterruptionIntervals =
driverTimelineBuilder.buildEsperDrivingInterruptionIntervalEvents(
sessionId,
driverKey,
timeline,
significantDrivingMinutes
);
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionIntervals =
clipEsperDrivingInterruptionIntervalEvents(
rawDrivingInterruptionIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperDrivingInterruptionIntervalEvent> drivingInterruptionVehicleChangeIntervals =
clipEsperDrivingInterruptionIntervalEvents(
driverTimelineBuilder.buildEsperDrivingInterruptionVehicleChangeIntervalEvents(
rawDrivingInterruptionIntervals
),
requestedFrom,
requestedTo
);
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals =
driverTimelineBuilder.buildEsperVuCardAbsentIntervalEvents(timeline);
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> potentialHomeOvernightStayIntervals =
clipEsperPotentialHomeOvernightStayIntervalEvents(
driverTimelineBuilder.buildEsperPotentialHomeOvernightStayIntervalEvents(
rawDrivingInterruptionIntervals,
rawVuCardAbsentIntervals,
minimumRestPeriodMinutes
),
rawVuCardAbsentIntervals,
requestedFrom,
requestedTo
);
List<TachographEsperVehicleUsageIntervalEvent> vehicleUsageIntervals = clipEsperVehicleUsageIntervalEvents(
driverTimelineBuilder.buildEsperVehicleUsageIntervalEvents(timeline),
requestedFrom,
requestedTo
);
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = clipEsperVuCardAbsentIntervalEvents(
driverTimelineBuilder.buildEsperVuCardAbsentIntervalEvents(timeline),
rawVuCardAbsentIntervals,
requestedFrom,
requestedTo
);
@ -192,11 +217,15 @@ public class TachographFileSessionProcessingService {
activityIntervals.size(),
drivingIntervals.size(),
drivingInterruptionIntervals.size(),
drivingInterruptionVehicleChangeIntervals.size(),
potentialHomeOvernightStayIntervals.size(),
vehicleUsageIntervals.size(),
vuCardAbsentIntervals.size(),
activityIntervals,
drivingIntervals,
drivingInterruptionIntervals,
drivingInterruptionVehicleChangeIntervals,
potentialHomeOvernightStayIntervals,
vehicleUsageIntervals,
vuCardAbsentIntervals,
esperProjectionNotes()
@ -310,6 +339,8 @@ public class TachographFileSessionProcessingService {
Duration.between(start, end).getSeconds(),
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey()
);
@ -355,6 +386,49 @@ public class TachographFileSessionProcessingService {
.toList();
}
private List<TachographEsperPotentialHomeOvernightStayIntervalEvent> clipEsperPotentialHomeOvernightStayIntervalEvents(
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals,
List<TachographEsperVuCardAbsentIntervalEvent> rawVuCardAbsentIntervals,
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();
long unknownDurationSeconds = overlapSeconds(start, end, rawVuCardAbsentIntervals, interval.driverKey());
double unknownCoveragePercent = durationSeconds == 0L
? 0.0d
: (unknownDurationSeconds * 100.0d) / durationSeconds;
return new TachographEsperPotentialHomeOvernightStayIntervalEvent(
interval.sessionId(),
interval.driverKey(),
start,
end,
durationSeconds,
unknownDurationSeconds,
unknownCoveragePercent,
interval.previousDrivingSourceIntervalId(),
interval.nextDrivingSourceIntervalId(),
interval.previousRegistrationKey(),
interval.nextRegistrationKey(),
interval.previousVehicleKey(),
interval.nextVehicleKey()
);
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::startedAt)
.thenComparing(TachographEsperPotentialHomeOvernightStayIntervalEvent::endedAt))
.toList();
}
private List<ResolvedActivityInterval> synthesizeUnknownGaps(
List<ResolvedActivityInterval> knownIntervals,
Duration gapDetectionTolerance
@ -899,6 +973,12 @@ public class TachographFileSessionProcessingService {
: request.significantDrivingMinutes();
}
private int resolveMinimumRestPeriodMinutes(TachographEsperEventsProcessingRequest request) {
return request.minimumRestPeriodMinutes() == null
? properties.getTachographFileSession().getProcessing().getMinimumRestPeriodMinutes()
: request.minimumRestPeriodMinutes();
}
private List<String> notes() {
return List.of(
"This endpoint evaluates operating periods from the in-memory tachograph file-session model.",
@ -913,12 +993,37 @@ public class TachographFileSessionProcessingService {
"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 DTI intervals where previousRegistrationKey differs from nextRegistrationKey.",
"Potential home overnight stay intervals are DTI intervals longer than the configured minimum rest-period threshold where VU card-absent overlap covers at least 95% of the DTI.",
"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 long overlapSeconds(
OffsetDateTime intervalStart,
OffsetDateTime intervalEnd,
List<TachographEsperVuCardAbsentIntervalEvent> unknownIntervals,
String driverKey
) {
if (unknownIntervals == null || unknownIntervals.isEmpty()) {
return 0L;
}
long total = 0L;
for (TachographEsperVuCardAbsentIntervalEvent unknown : unknownIntervals) {
if (!Objects.equals(driverKey, unknown.driverKey())) {
continue;
}
OffsetDateTime overlapStart = max(intervalStart, unknown.startedAt());
OffsetDateTime overlapEnd = min(intervalEnd, unknown.endedAt());
if (overlapEnd.isAfter(overlapStart)) {
total += Duration.between(overlapStart, overlapEnd).getSeconds();
}
}
return total;
}
private OffsetDateTime utc(OffsetDateTime value) {
return value == null ? null : value.withOffsetSameInstant(java.time.ZoneOffset.UTC);
}

View File

@ -6,6 +6,7 @@ create schema SignificantDrivingInterval(
startedAtEpochSecond long,
endedAtEpochSecond long,
durationSeconds long,
registrationKey string,
vehicleKey string
);
@ -17,6 +18,8 @@ create schema DrivingInterruptionInterval(
durationSeconds long,
previousDrivingSourceIntervalId string,
nextDrivingSourceIntervalId string,
previousRegistrationKey string,
nextRegistrationKey string,
previousVehicleKey string,
nextVehicleKey string
);
@ -30,6 +33,7 @@ select
startedAtEpochSecond,
endedAtEpochSecond,
durationSeconds,
registrationKey,
vehicleKey
from TachographActivityIntervalInputEvent(activityType = 'DRIVE', durationSeconds > ${SIGNIFICANT_DRIVING_THRESHOLD_SECONDS});
@ -45,6 +49,8 @@ select
next.startedAtEpochSecond - priorInterval.endedAtEpochSecond as durationSeconds,
priorInterval.lastSourceIntervalId as previousDrivingSourceIntervalId,
next.firstSourceIntervalId as nextDrivingSourceIntervalId,
priorInterval.registrationKey as previousRegistrationKey,
next.registrationKey as nextRegistrationKey,
priorInterval.vehicleKey as previousVehicleKey,
next.vehicleKey as nextVehicleKey
from PreviousSignificantDrivingInterval as priorInterval

View File

@ -0,0 +1,18 @@
@name('drivingInterruptionVehicleChangeIntervals')
select
sessionId,
driverKey,
startedAt,
endedAt,
durationSeconds,
previousDrivingSourceIntervalId,
nextDrivingSourceIntervalId,
previousRegistrationKey,
nextRegistrationKey,
previousVehicleKey,
nextVehicleKey
from TachographDrivingInterruptionIntervalInputEvent(
previousRegistrationKey is not null,
nextRegistrationKey is not null,
previousRegistrationKey != nextRegistrationKey
);

View File

@ -0,0 +1,65 @@
@name('potentialHomeOvernightStayIntervals')
select
d.sessionId as sessionId,
d.driverKey as driverKey,
d.startedAt as startedAt,
d.endedAt as endedAt,
d.durationSeconds as durationSeconds,
sum(
case
when u.startedAtEpochSecond <= d.startedAtEpochSecond and u.endedAtEpochSecond >= d.endedAtEpochSecond
then d.durationSeconds
when u.startedAtEpochSecond <= d.startedAtEpochSecond
then u.endedAtEpochSecond - d.startedAtEpochSecond
when u.endedAtEpochSecond >= d.endedAtEpochSecond
then d.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) as unknownDurationSeconds,
(sum(
case
when u.startedAtEpochSecond <= d.startedAtEpochSecond and u.endedAtEpochSecond >= d.endedAtEpochSecond
then d.durationSeconds
when u.startedAtEpochSecond <= d.startedAtEpochSecond
then u.endedAtEpochSecond - d.startedAtEpochSecond
when u.endedAtEpochSecond >= d.endedAtEpochSecond
then d.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) * 100.0d) / d.durationSeconds as unknownCoveragePercent,
d.previousDrivingSourceIntervalId as previousDrivingSourceIntervalId,
d.nextDrivingSourceIntervalId as nextDrivingSourceIntervalId,
d.previousRegistrationKey as previousRegistrationKey,
d.nextRegistrationKey as nextRegistrationKey,
d.previousVehicleKey as previousVehicleKey,
d.nextVehicleKey as nextVehicleKey
from TachographDrivingInterruptionIntervalInputEvent(durationSeconds > ${POTENTIAL_HOME_OVERNIGHT_STAY_THRESHOLD_SECONDS}) as d unidirectional,
TachographVuCardAbsentIntervalInputEvent#keepall as u
where u.driverKey = d.driverKey
and u.startedAtEpochSecond < d.endedAtEpochSecond
and u.endedAtEpochSecond > d.startedAtEpochSecond
group by
d.sessionId,
d.driverKey,
d.startedAt,
d.endedAt,
d.startedAtEpochSecond,
d.endedAtEpochSecond,
d.durationSeconds,
d.previousDrivingSourceIntervalId,
d.nextDrivingSourceIntervalId,
d.previousRegistrationKey,
d.nextRegistrationKey,
d.previousVehicleKey,
d.nextVehicleKey
having sum(
case
when u.startedAtEpochSecond <= d.startedAtEpochSecond and u.endedAtEpochSecond >= d.endedAtEpochSecond
then d.durationSeconds
when u.startedAtEpochSecond <= d.startedAtEpochSecond
then u.endedAtEpochSecond - d.startedAtEpochSecond
when u.endedAtEpochSecond >= d.endedAtEpochSecond
then d.endedAtEpochSecond - u.startedAtEpochSecond
else u.endedAtEpochSecond - u.startedAtEpochSecond
end
) * 100L >= d.durationSeconds * 95L;

View File

@ -21,6 +21,7 @@ import at.procon.eventhub.tachographfilesession.dto.TachographFileSessionSummary
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import at.procon.eventhub.tachographfilesession.service.TachographFileSessionProcessingService;
@ -81,6 +82,8 @@ class TachographFileSessionControllerTest {
2,
1,
1,
1,
1,
2,
1,
List.of(new TachographEsperActivityIntervalEvent(
@ -129,6 +132,36 @@ class TachographFileSessionControllerTest {
1800L,
"ACT-2",
"ACT-3",
"12:REG-1",
"12:REG-2",
"VIN-1",
"VIN-2"
)),
List.of(new TachographEsperDrivingInterruptionIntervalEvent(
sessionId,
"12:123",
OffsetDateTime.parse("2026-05-12T10:00:00Z"),
OffsetDateTime.parse("2026-05-12T10:30:00Z"),
1800L,
"ACT-2",
"ACT-3",
"12:REG-1",
"12:REG-2",
"VIN-1",
"VIN-2"
)),
List.of(new TachographEsperPotentialHomeOvernightStayIntervalEvent(
sessionId,
"12:123",
OffsetDateTime.parse("2026-05-12T10:00:00Z"),
OffsetDateTime.parse("2026-05-12T22:00:00Z"),
43_200L,
43_200L,
100.0d,
"ACT-2",
"ACT-3",
"12:REG-1",
"12:REG-2",
"VIN-1",
"VIN-2"
)),
@ -213,7 +246,8 @@ class TachographFileSessionControllerTest {
{
"occurredFrom": "2026-05-12T08:30:00Z",
"occurredTo": "2026-05-12T11:30:00Z",
"significantDrivingMinutes": 3
"significantDrivingMinutes": 3,
"minimumRestPeriodMinutes": 720
}
"""))
.andExpect(status().isOk())
@ -223,9 +257,17 @@ class TachographFileSessionControllerTest {
.andExpect(jsonPath("$.requestedTo").value("2026-05-12T11:30:00Z"))
.andExpect(jsonPath("$.activityIntervalCount").value(2))
.andExpect(jsonPath("$.drivingInterruptionIntervalCount").value(1))
.andExpect(jsonPath("$.drivingInterruptionVehicleChangeIntervalCount").value(1))
.andExpect(jsonPath("$.potentialHomeOvernightStayIntervalCount").value(1))
.andExpect(jsonPath("$.vuCardAbsentIntervalCount").value(1))
.andExpect(jsonPath("$.drivingInterruptionIntervals[0].previousRegistrationKey").value("12:REG-1"))
.andExpect(jsonPath("$.drivingInterruptionIntervals[0].nextRegistrationKey").value("12:REG-2"))
.andExpect(jsonPath("$.drivingInterruptionIntervals[0].previousVehicleKey").value("VIN-1"))
.andExpect(jsonPath("$.drivingInterruptionIntervals[0].nextVehicleKey").value("VIN-2"))
.andExpect(jsonPath("$.drivingInterruptionVehicleChangeIntervals[0].previousRegistrationKey").value("12:REG-1"))
.andExpect(jsonPath("$.drivingInterruptionVehicleChangeIntervals[0].nextRegistrationKey").value("12:REG-2"))
.andExpect(jsonPath("$.drivingInterruptionVehicleChangeIntervals[0].previousVehicleKey").value("VIN-1"))
.andExpect(jsonPath("$.drivingInterruptionVehicleChangeIntervals[0].nextVehicleKey").value("VIN-2"))
.andExpect(jsonPath("$.drivingIntervals[0].activityType").value("DRIVE"));
mockMvc.perform(post("/api/eventhub/tachograph-file-sessions/{sessionId}/drivers/{driverKey}/processing/operating-periods", sessionId, "12:123")

View File

@ -11,6 +11,7 @@ import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHomeOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVehicleUsageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
@ -448,7 +449,72 @@ class DriverTimelineBuilderTest {
assertThat(interruptions.get(0).durationSeconds()).isEqualTo(480L);
assertThat(interruptions.get(0).previousDrivingSourceIntervalId()).isEqualTo("ACT-1");
assertThat(interruptions.get(0).nextDrivingSourceIntervalId()).isEqualTo("ACT-3");
assertThat(interruptions.get(0).previousRegistrationKey()).isEqualTo("12:REG-1");
assertThat(interruptions.get(0).nextRegistrationKey()).isEqualTo("12:REG-2");
assertThat(interruptions.get(0).previousVehicleKey()).isEqualTo("VIN-1");
assertThat(interruptions.get(0).nextVehicleKey()).isEqualTo("VIN-2");
List<TachographEsperDrivingInterruptionIntervalEvent> vehicleChangeInterruptions =
builder.buildEsperDrivingInterruptionVehicleChangeIntervalEvents(interruptions);
assertThat(vehicleChangeInterruptions).hasSize(1);
assertThat(vehicleChangeInterruptions.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:02:00Z"));
assertThat(vehicleChangeInterruptions.get(0).previousRegistrationKey()).isEqualTo("12:REG-1");
assertThat(vehicleChangeInterruptions.get(0).nextRegistrationKey()).isEqualTo("12:REG-2");
assertThat(vehicleChangeInterruptions.get(0).previousVehicleKey()).isEqualTo("VIN-1");
assertThat(vehicleChangeInterruptions.get(0).nextVehicleKey()).isEqualTo("VIN-2");
}
@Test
void buildsPotentialHomeOvernightStayIntervalsFromDtiAndVuCardAbsentOverlap() {
UUID sessionId = UUID.randomUUID();
List<TachographEsperDrivingInterruptionIntervalEvent> interruptions = List.of(
new TachographEsperDrivingInterruptionIntervalEvent(
sessionId,
"12:123",
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
50_400L,
"ACT-1",
"ACT-2",
"12:REG-1",
"12:REG-1",
"VIN-1",
"VIN-1"
)
);
List<TachographEsperVuCardAbsentIntervalEvent> vuCardAbsentIntervals = List.of(
new TachographEsperVuCardAbsentIntervalEvent(
sessionId,
"12:123",
OffsetDateTime.parse("2026-05-01T10:00:01Z"),
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
50_399L,
"CVU-1",
"CVU-2",
"12:REG-1",
"12:REG-1",
"VIN-1",
"VIN-1"
)
);
List<TachographEsperPotentialHomeOvernightStayIntervalEvent> intervals =
builder.buildEsperPotentialHomeOvernightStayIntervalEvents(
interruptions,
vuCardAbsentIntervals,
720
);
assertThat(intervals).hasSize(1);
assertThat(intervals.get(0).sessionId()).isEqualTo(sessionId);
assertThat(intervals.get(0).driverKey()).isEqualTo("12:123");
assertThat(intervals.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(intervals.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-02T00:00:00Z"));
assertThat(intervals.get(0).durationSeconds()).isEqualTo(50_400L);
assertThat(intervals.get(0).unknownDurationSeconds()).isEqualTo(50_399L);
assertThat(intervals.get(0).unknownCoveragePercent()).isGreaterThan(99.9d);
assertThat(intervals.get(0).previousDrivingSourceIntervalId()).isEqualTo("ACT-1");
assertThat(intervals.get(0).nextDrivingSourceIntervalId()).isEqualTo("ACT-2");
}
}

View File

@ -86,6 +86,8 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.activityIntervalCount()).isEqualTo(2);
assertThat(result.drivingIntervalCount()).isEqualTo(1);
assertThat(result.drivingInterruptionIntervalCount()).isEqualTo(0);
assertThat(result.drivingInterruptionVehicleChangeIntervalCount()).isEqualTo(0);
assertThat(result.potentialHomeOvernightStayIntervalCount()).isEqualTo(0);
assertThat(result.vehicleUsageIntervalCount()).isEqualTo(2);
assertThat(result.vuCardAbsentIntervalCount()).isEqualTo(1);
assertThat(result.vuCardAbsentIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:01Z"));
@ -155,7 +157,8 @@ class TachographFileSessionProcessingServiceTest {
new TachographEsperEventsProcessingRequest(
OffsetDateTime.parse("2026-05-01T08:45:00Z"),
OffsetDateTime.parse("2026-05-01T12:30:00Z"),
3
3,
720
)
);
@ -166,10 +169,19 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.activityIntervals().get(0).clippedToRequestedPeriod()).isTrue();
assertThat(result.drivingIntervalCount()).isEqualTo(2);
assertThat(result.drivingInterruptionIntervalCount()).isEqualTo(1);
assertThat(result.drivingInterruptionVehicleChangeIntervalCount()).isEqualTo(1);
assertThat(result.potentialHomeOvernightStayIntervalCount()).isEqualTo(0);
assertThat(result.drivingInterruptionIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T09:00:00Z"));
assertThat(result.drivingInterruptionIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T10:00:00Z"));
assertThat(result.drivingInterruptionIntervals().get(0).previousRegistrationKey()).isEqualTo("12:REG-1");
assertThat(result.drivingInterruptionIntervals().get(0).nextRegistrationKey()).isEqualTo("12:REG-2");
assertThat(result.drivingInterruptionIntervals().get(0).previousVehicleKey()).isEqualTo("VIN-1");
assertThat(result.drivingInterruptionIntervals().get(0).nextVehicleKey()).isEqualTo("VIN-2");
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T09:00:00Z"));
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).previousRegistrationKey()).isEqualTo("12:REG-1");
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).nextRegistrationKey()).isEqualTo("12:REG-2");
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).previousVehicleKey()).isEqualTo("VIN-1");
assertThat(result.drivingInterruptionVehicleChangeIntervals().get(0).nextVehicleKey()).isEqualTo("VIN-2");
assertThat(result.vehicleUsageIntervalCount()).isEqualTo(2);
assertThat(result.vehicleUsageIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:45:00Z"));
assertThat(result.vehicleUsageIntervals().get(0).odometerBeginKm()).isNull();
@ -178,6 +190,80 @@ class TachographFileSessionProcessingServiceTest {
assertThat(result.vuCardAbsentIntervalCount()).isEqualTo(1);
}
@Test
void returnsPotentialHomeOvernightStayIntervalsWhenVuCardAbsentCoversLongDti() {
EventHubProperties properties = new EventHubProperties();
TachographFileSessionRepository repository = new InMemoryTachographFileSessionRepository(properties);
TachographFileSessionProcessingService service = new TachographFileSessionProcessingService(
repository,
new DriverTimelineBuilder(),
properties
);
DriverExtractionSession driver = new DriverExtractionSession(
"12:123",
null,
null,
List.of(),
List.of(),
List.of(
new ExtractedCardVehicleUsageInterval(
"CVU-1",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T10:00:00Z"),
100L,
200L,
"12:REG-1",
"VIN-1",
"vu-1"
),
new ExtractedCardVehicleUsageInterval(
"CVU-2",
OffsetDateTime.parse("2026-05-02T00:00:00Z"),
OffsetDateTime.parse("2026-05-02T02:00:00Z"),
201L,
260L,
"12:REG-1",
"VIN-1",
"vu-2"
)
),
List.of(
new ExtractedCardActivityInterval("ACT-1", OffsetDateTime.parse("2026-05-01T08:00:00Z"), OffsetDateTime.parse("2026-05-01T10:00:00Z"), "DRIVE", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "a"),
new ExtractedCardActivityInterval("ACT-2", OffsetDateTime.parse("2026-05-02T00:00:00Z"), OffsetDateTime.parse("2026-05-02T00:30:00Z"), "DRIVE", "DRIVER", "INSERTED", "SINGLE", "12:REG-1", "VIN-1", "b")
),
List.of(),
List.of()
);
TachographFileSession session = 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)
);
repository.save(session);
TachographEsperDriverProcessingResultDto result = service.getEsperDriverProcessingResults(
session.sessionId(),
driver.driverKey(),
new TachographEsperEventsProcessingRequest(
OffsetDateTime.parse("2026-05-01T11:00:00Z"),
OffsetDateTime.parse("2026-05-01T23:00:00Z"),
3,
720
)
);
assertThat(result.potentialHomeOvernightStayIntervalCount()).isEqualTo(1);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T11:00:00Z"));
assertThat(result.potentialHomeOvernightStayIntervals().get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T23:00:00Z"));
assertThat(result.potentialHomeOvernightStayIntervals().get(0).unknownDurationSeconds()).isEqualTo(43_200L);
assertThat(result.potentialHomeOvernightStayIntervals().get(0).unknownCoveragePercent()).isEqualTo(100.0d);
}
@Test
void evaluatesOperatingPeriodsFromSessionTimeline() {
EventHubProperties properties = new EventHubProperties();