Add Esper operating-period evaluation PoC
This commit is contained in:
parent
818009555a
commit
94767bc161
|
|
@ -57,6 +57,68 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Evaluate tachograph operating periods",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/eventhub/esper-poc/tachograph/operating-period-evaluation?tenantKey={{tenantKey}}&driverId={{driverId}}&occurredFrom={{occurredFrom}}&occurredTo={{occurredTo}}&guardHours=24&operatingSplitIdleHours=7&significantDrivingMinutes=3&mergeGapSeconds=0&gapDetectionToleranceSeconds=0&unknownTreatmentMode=AS_BREAK_REST",
|
||||||
|
"host": [
|
||||||
|
"{{baseUrl}}"
|
||||||
|
],
|
||||||
|
"path": [
|
||||||
|
"api",
|
||||||
|
"eventhub",
|
||||||
|
"esper-poc",
|
||||||
|
"tachograph",
|
||||||
|
"operating-period-evaluation"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "tenantKey",
|
||||||
|
"value": "{{tenantKey}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "driverId",
|
||||||
|
"value": "{{driverId}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "occurredFrom",
|
||||||
|
"value": "{{occurredFrom}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "occurredTo",
|
||||||
|
"value": "{{occurredTo}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "guardHours",
|
||||||
|
"value": "24"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "operatingSplitIdleHours",
|
||||||
|
"value": "7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "significantDrivingMinutes",
|
||||||
|
"value": "3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "mergeGapSeconds",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "gapDetectionToleranceSeconds",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "unknownTreatmentMode",
|
||||||
|
"value": "AS_BREAK_REST"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"variable": [
|
"variable": [
|
||||||
|
|
@ -72,6 +134,10 @@
|
||||||
"key": "driverEntityId",
|
"key": "driverEntityId",
|
||||||
"value": "00000000-0000-0000-0000-000000000000"
|
"value": "00000000-0000-0000-0000-000000000000"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "driverId",
|
||||||
|
"value": "00000000-0000-0000-0000-000000000000"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "occurredFrom",
|
"key": "occurredFrom",
|
||||||
"value": "2026-04-01T00:00:00Z"
|
"value": "2026-04-01T00:00:00Z"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package at.procon.eventhub.config;
|
package at.procon.eventhub.config;
|
||||||
|
|
||||||
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
|
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode;
|
||||||
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
|
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
@ -50,6 +51,7 @@ public class EventHubProperties {
|
||||||
public static class EsperPoc {
|
public static class EsperPoc {
|
||||||
private EsperActivityMergeMode activityMergeMode = EsperActivityMergeMode.JAVA;
|
private EsperActivityMergeMode activityMergeMode = EsperActivityMergeMode.JAVA;
|
||||||
private EsperShiftResolutionMode shiftResolutionMode = EsperShiftResolutionMode.JAVA;
|
private EsperShiftResolutionMode shiftResolutionMode = EsperShiftResolutionMode.JAVA;
|
||||||
|
private final OperatingPeriodEvaluation operatingPeriodEvaluation = new OperatingPeriodEvaluation();
|
||||||
|
|
||||||
public EsperActivityMergeMode getActivityMergeMode() {
|
public EsperActivityMergeMode getActivityMergeMode() {
|
||||||
return activityMergeMode;
|
return activityMergeMode;
|
||||||
|
|
@ -66,6 +68,60 @@ public class EventHubProperties {
|
||||||
public void setShiftResolutionMode(EsperShiftResolutionMode shiftResolutionMode) {
|
public void setShiftResolutionMode(EsperShiftResolutionMode shiftResolutionMode) {
|
||||||
this.shiftResolutionMode = shiftResolutionMode == null ? EsperShiftResolutionMode.JAVA : shiftResolutionMode;
|
this.shiftResolutionMode = shiftResolutionMode == null ? EsperShiftResolutionMode.JAVA : shiftResolutionMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OperatingPeriodEvaluation getOperatingPeriodEvaluation() {
|
||||||
|
return operatingPeriodEvaluation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OperatingPeriodEvaluation {
|
||||||
|
private int operatingSplitIdleHours = 7;
|
||||||
|
private int significantDrivingMinutes = 3;
|
||||||
|
private int mergeGapSeconds = 0;
|
||||||
|
private int gapDetectionToleranceSeconds = 0;
|
||||||
|
private EsperUnknownTreatmentMode unknownTreatmentMode = EsperUnknownTreatmentMode.AS_BREAK_REST;
|
||||||
|
|
||||||
|
public int getOperatingSplitIdleHours() {
|
||||||
|
return operatingSplitIdleHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOperatingSplitIdleHours(int operatingSplitIdleHours) {
|
||||||
|
this.operatingSplitIdleHours = Math.max(1, operatingSplitIdleHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSignificantDrivingMinutes() {
|
||||||
|
return significantDrivingMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSignificantDrivingMinutes(int significantDrivingMinutes) {
|
||||||
|
this.significantDrivingMinutes = Math.max(1, significantDrivingMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMergeGapSeconds() {
|
||||||
|
return mergeGapSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMergeGapSeconds(int mergeGapSeconds) {
|
||||||
|
this.mergeGapSeconds = Math.max(0, mergeGapSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getGapDetectionToleranceSeconds() {
|
||||||
|
return gapDetectionToleranceSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setGapDetectionToleranceSeconds(int gapDetectionToleranceSeconds) {
|
||||||
|
this.gapDetectionToleranceSeconds = Math.max(0, gapDetectionToleranceSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public EsperUnknownTreatmentMode getUnknownTreatmentMode() {
|
||||||
|
return unknownTreatmentMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUnknownTreatmentMode(EsperUnknownTreatmentMode unknownTreatmentMode) {
|
||||||
|
this.unknownTreatmentMode = unknownTreatmentMode == null
|
||||||
|
? EsperUnknownTreatmentMode.AS_BREAK_REST
|
||||||
|
: unknownTreatmentMode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Batch {
|
public static class Batch {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
package at.procon.eventhub.esperpoc.api;
|
package at.procon.eventhub.esperpoc.api;
|
||||||
|
|
||||||
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
|
import at.procon.eventhub.esperpoc.dto.EsperActivityMergeMode;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodRequest;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodResultDto;
|
||||||
import at.procon.eventhub.esperpoc.dto.EsperPocRequest;
|
import at.procon.eventhub.esperpoc.dto.EsperPocRequest;
|
||||||
import at.procon.eventhub.esperpoc.dto.EsperPocResultDto;
|
import at.procon.eventhub.esperpoc.dto.EsperPocResultDto;
|
||||||
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
|
import at.procon.eventhub.esperpoc.dto.EsperShiftResolutionMode;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode;
|
||||||
|
import at.procon.eventhub.esperpoc.service.EsperOperatingPeriodEvaluationService;
|
||||||
import at.procon.eventhub.esperpoc.service.EsperPocDriverCardActivityService;
|
import at.procon.eventhub.esperpoc.service.EsperPocDriverCardActivityService;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
@ -19,9 +23,14 @@ import org.springframework.web.bind.annotation.RestController;
|
||||||
public class EsperPocController {
|
public class EsperPocController {
|
||||||
|
|
||||||
private final EsperPocDriverCardActivityService service;
|
private final EsperPocDriverCardActivityService service;
|
||||||
|
private final EsperOperatingPeriodEvaluationService operatingPeriodEvaluationService;
|
||||||
|
|
||||||
public EsperPocController(EsperPocDriverCardActivityService service) {
|
public EsperPocController(
|
||||||
|
EsperPocDriverCardActivityService service,
|
||||||
|
EsperOperatingPeriodEvaluationService operatingPeriodEvaluationService
|
||||||
|
) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
|
this.operatingPeriodEvaluationService = operatingPeriodEvaluationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/tachograph/driver-card-activities")
|
@GetMapping("/tachograph/driver-card-activities")
|
||||||
|
|
@ -55,4 +64,32 @@ public class EsperPocController {
|
||||||
);
|
);
|
||||||
return ResponseEntity.ok(service.evaluate(request));
|
return ResponseEntity.ok(service.evaluate(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tachograph/operating-period-evaluation")
|
||||||
|
public ResponseEntity<EsperOperatingPeriodResultDto> evaluateOperatingPeriods(
|
||||||
|
@RequestParam String tenantKey,
|
||||||
|
@RequestParam UUID driverId,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime occurredFrom,
|
||||||
|
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) OffsetDateTime occurredTo,
|
||||||
|
@RequestParam(defaultValue = "24") Integer guardHours,
|
||||||
|
@RequestParam(required = false) Integer operatingSplitIdleHours,
|
||||||
|
@RequestParam(required = false) Integer significantDrivingMinutes,
|
||||||
|
@RequestParam(required = false) Integer mergeGapSeconds,
|
||||||
|
@RequestParam(required = false) Integer gapDetectionToleranceSeconds,
|
||||||
|
@RequestParam(required = false) EsperUnknownTreatmentMode unknownTreatmentMode
|
||||||
|
) {
|
||||||
|
EsperOperatingPeriodRequest request = new EsperOperatingPeriodRequest(
|
||||||
|
tenantKey,
|
||||||
|
driverId,
|
||||||
|
occurredFrom,
|
||||||
|
occurredTo,
|
||||||
|
guardHours,
|
||||||
|
operatingSplitIdleHours,
|
||||||
|
significantDrivingMinutes,
|
||||||
|
mergeGapSeconds,
|
||||||
|
gapDetectionToleranceSeconds,
|
||||||
|
unknownTreatmentMode
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(operatingPeriodEvaluationService.evaluate(request));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
package at.procon.eventhub.esperpoc.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record EsperOperatingPeriodRequest(
|
||||||
|
@NotBlank String tenantKey,
|
||||||
|
@NotNull UUID driverId,
|
||||||
|
@NotNull OffsetDateTime occurredFrom,
|
||||||
|
@NotNull OffsetDateTime occurredTo,
|
||||||
|
Integer guardHours,
|
||||||
|
Integer operatingSplitIdleHours,
|
||||||
|
Integer significantDrivingMinutes,
|
||||||
|
Integer mergeGapSeconds,
|
||||||
|
Integer gapDetectionToleranceSeconds,
|
||||||
|
EsperUnknownTreatmentMode unknownTreatmentMode
|
||||||
|
) {
|
||||||
|
public EsperOperatingPeriodRequest {
|
||||||
|
if (occurredFrom != null && occurredTo != null && !occurredFrom.isBefore(occurredTo)) {
|
||||||
|
throw new IllegalArgumentException("occurredFrom must be before occurredTo");
|
||||||
|
}
|
||||||
|
guardHours = guardHours == null ? 24 : Math.max(0, guardHours);
|
||||||
|
operatingSplitIdleHours = operatingSplitIdleHours == null ? null : Math.max(1, operatingSplitIdleHours);
|
||||||
|
significantDrivingMinutes = significantDrivingMinutes == null ? null : Math.max(1, significantDrivingMinutes);
|
||||||
|
mergeGapSeconds = mergeGapSeconds == null ? null : Math.max(0, mergeGapSeconds);
|
||||||
|
gapDetectionToleranceSeconds = gapDetectionToleranceSeconds == null ? null : Math.max(0, gapDetectionToleranceSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package at.procon.eventhub.esperpoc.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record EsperOperatingPeriodResultDto(
|
||||||
|
String tenantKey,
|
||||||
|
UUID driverId,
|
||||||
|
OffsetDateTime requestedFrom,
|
||||||
|
OffsetDateTime requestedTo,
|
||||||
|
OffsetDateTime loadedFrom,
|
||||||
|
OffsetDateTime loadedTo,
|
||||||
|
int rawEventCount,
|
||||||
|
int driverCardRawEventCount,
|
||||||
|
int vehicleUnitRawEventCount,
|
||||||
|
int driverCardIntervalCount,
|
||||||
|
int vehicleUnitIntervalCount,
|
||||||
|
int resolvedKnownIntervalCount,
|
||||||
|
int evaluationIntervalCount,
|
||||||
|
int periodizedIntervalCount,
|
||||||
|
int mergedIntervalCount,
|
||||||
|
int nonDrivingIntervalCount,
|
||||||
|
int operatingPeriodCount,
|
||||||
|
int operatingSplitIdleHours,
|
||||||
|
int significantDrivingMinutes,
|
||||||
|
int mergeGapSeconds,
|
||||||
|
int gapDetectionToleranceSeconds,
|
||||||
|
EsperUnknownTreatmentMode unknownTreatmentMode,
|
||||||
|
List<RawActivityEventDto> rawEvents,
|
||||||
|
List<ActivityIntervalDto> resolvedKnownIntervals,
|
||||||
|
List<ActivityIntervalDto> evaluationIntervals,
|
||||||
|
List<OperatingPeriodActivityIntervalDto> periodizedIntervals,
|
||||||
|
List<OperatingPeriodActivityIntervalDto> mergedIntervals,
|
||||||
|
List<NonDrivingIntervalDto> nonDrivingIntervals,
|
||||||
|
List<OperatingPeriodDto> operatingPeriods,
|
||||||
|
List<String> notes
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package at.procon.eventhub.esperpoc.dto;
|
||||||
|
|
||||||
|
public enum EsperUnknownTreatmentMode {
|
||||||
|
AS_BREAK_REST,
|
||||||
|
AS_UNKNOWN_NON_BREAK
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package at.procon.eventhub.esperpoc.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record NonDrivingIntervalDto(
|
||||||
|
UUID driverId,
|
||||||
|
UUID vehicleId,
|
||||||
|
UUID vehicleRegistrationId,
|
||||||
|
String cardSlot,
|
||||||
|
OffsetDateTime startedAt,
|
||||||
|
OffsetDateTime endedAt,
|
||||||
|
long durationSeconds,
|
||||||
|
long operatingPeriodNo,
|
||||||
|
OffsetDateTime operatingPeriodStartedAt,
|
||||||
|
String closedBy,
|
||||||
|
boolean containsUnknown,
|
||||||
|
long unknownSeconds,
|
||||||
|
boolean clippedToRequestedPeriod
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
package at.procon.eventhub.esperpoc.dto;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record OperatingPeriodActivityIntervalDto(
|
||||||
|
UUID driverId,
|
||||||
|
UUID vehicleId,
|
||||||
|
UUID vehicleRegistrationId,
|
||||||
|
String activityType,
|
||||||
|
String cardSlot,
|
||||||
|
String cardStatus,
|
||||||
|
String drivingStatus,
|
||||||
|
String sourceKind,
|
||||||
|
OffsetDateTime startedAt,
|
||||||
|
OffsetDateTime endedAt,
|
||||||
|
long durationSeconds,
|
||||||
|
String sourceRowId,
|
||||||
|
List<String> sourceRowIds,
|
||||||
|
boolean clippedToRequestedPeriod,
|
||||||
|
String level,
|
||||||
|
long operatingPeriodNo,
|
||||||
|
OffsetDateTime operatingPeriodStartedAt,
|
||||||
|
boolean newOperatingPeriod,
|
||||||
|
Long gapSincePreviousActivitySeconds,
|
||||||
|
boolean synthetic
|
||||||
|
) {
|
||||||
|
public static OperatingPeriodActivityIntervalDto periodized(
|
||||||
|
ActivityIntervalDto interval,
|
||||||
|
long operatingPeriodNo,
|
||||||
|
OffsetDateTime operatingPeriodStartedAt,
|
||||||
|
boolean newOperatingPeriod,
|
||||||
|
Long gapSincePreviousActivitySeconds
|
||||||
|
) {
|
||||||
|
return new OperatingPeriodActivityIntervalDto(
|
||||||
|
interval.driverEntityId(),
|
||||||
|
interval.vehicleId(),
|
||||||
|
interval.vehicleRegistrationId(),
|
||||||
|
interval.activityType(),
|
||||||
|
interval.cardSlot(),
|
||||||
|
interval.cardStatus(),
|
||||||
|
interval.drivingStatus(),
|
||||||
|
interval.sourceKind(),
|
||||||
|
interval.startedAt(),
|
||||||
|
interval.endedAt(),
|
||||||
|
interval.durationSeconds(),
|
||||||
|
interval.sourceRowId(),
|
||||||
|
interval.sourceRowIds(),
|
||||||
|
interval.clippedToRequestedPeriod(),
|
||||||
|
"PERIODIZED_ACTIVITY",
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
newOperatingPeriod,
|
||||||
|
gapSincePreviousActivitySeconds,
|
||||||
|
"UNKNOWN_GAP".equals(interval.level())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OperatingPeriodActivityIntervalDto withTime(OffsetDateTime newStartedAt, OffsetDateTime newEndedAt, boolean clipped) {
|
||||||
|
return new OperatingPeriodActivityIntervalDto(
|
||||||
|
driverId,
|
||||||
|
vehicleId,
|
||||||
|
vehicleRegistrationId,
|
||||||
|
activityType,
|
||||||
|
cardSlot,
|
||||||
|
cardStatus,
|
||||||
|
drivingStatus,
|
||||||
|
sourceKind,
|
||||||
|
newStartedAt,
|
||||||
|
newEndedAt,
|
||||||
|
Duration.between(newStartedAt, newEndedAt).getSeconds(),
|
||||||
|
sourceRowId,
|
||||||
|
sourceRowIds,
|
||||||
|
clipped,
|
||||||
|
level,
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
newOperatingPeriod,
|
||||||
|
gapSincePreviousActivitySeconds,
|
||||||
|
synthetic
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public OperatingPeriodActivityIntervalDto asMerged(OffsetDateTime newEndedAt, List<String> mergedSourceRowIds) {
|
||||||
|
return new OperatingPeriodActivityIntervalDto(
|
||||||
|
driverId,
|
||||||
|
vehicleId,
|
||||||
|
vehicleRegistrationId,
|
||||||
|
activityType,
|
||||||
|
cardSlot,
|
||||||
|
cardStatus,
|
||||||
|
drivingStatus,
|
||||||
|
sourceKind,
|
||||||
|
startedAt,
|
||||||
|
newEndedAt,
|
||||||
|
Duration.between(startedAt, newEndedAt).getSeconds(),
|
||||||
|
sourceRowId,
|
||||||
|
mergedSourceRowIds,
|
||||||
|
clippedToRequestedPeriod,
|
||||||
|
"MERGED_ACTIVITY",
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
newOperatingPeriod,
|
||||||
|
gapSincePreviousActivitySeconds,
|
||||||
|
synthetic
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package at.procon.eventhub.esperpoc.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public record OperatingPeriodDto(
|
||||||
|
long operatingPeriodNo,
|
||||||
|
OffsetDateTime startedAt,
|
||||||
|
OffsetDateTime endedAt,
|
||||||
|
long durationSeconds,
|
||||||
|
String closedBy,
|
||||||
|
long drivingSeconds,
|
||||||
|
long workSeconds,
|
||||||
|
long availabilitySeconds,
|
||||||
|
long unknownSeconds,
|
||||||
|
int intervalCount,
|
||||||
|
boolean clippedToRequestedPeriod
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package at.procon.eventhub.esperpoc.service;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
record EsperClosedOperatingPeriod(
|
||||||
|
long operatingPeriodNo,
|
||||||
|
OffsetDateTime startedAt,
|
||||||
|
OffsetDateTime endedAt,
|
||||||
|
long durationSeconds,
|
||||||
|
String closedBy
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
package at.procon.eventhub.esperpoc.service;
|
||||||
|
|
||||||
|
import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.OperatingPeriodActivityIntervalDto;
|
||||||
|
import com.espertech.esper.common.client.EPCompiled;
|
||||||
|
import com.espertech.esper.common.client.EventBean;
|
||||||
|
import com.espertech.esper.common.client.configuration.Configuration;
|
||||||
|
import com.espertech.esper.compiler.client.CompilerArguments;
|
||||||
|
import com.espertech.esper.compiler.client.EPCompileException;
|
||||||
|
import com.espertech.esper.compiler.client.EPCompilerProvider;
|
||||||
|
import com.espertech.esper.runtime.client.EPDeployException;
|
||||||
|
import com.espertech.esper.runtime.client.EPDeployment;
|
||||||
|
import com.espertech.esper.runtime.client.EPRuntime;
|
||||||
|
import com.espertech.esper.runtime.client.EPRuntimeProvider;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class EsperOperatingPeriodEngine {
|
||||||
|
|
||||||
|
private static final AtomicLong RUNTIME_COUNTER = new AtomicLong();
|
||||||
|
private static final String INPUT_STREAM_EPL = """
|
||||||
|
@name('operatingPeriodIntervalStream')
|
||||||
|
select * from OperatingPeriodIntervalInputEvent
|
||||||
|
""";
|
||||||
|
|
||||||
|
public EsperOperatingPeriodEvaluation evaluate(
|
||||||
|
List<ActivityIntervalDto> intervals,
|
||||||
|
Duration operatingSplitIdleThreshold
|
||||||
|
) {
|
||||||
|
List<ActivityIntervalDto> sorted = sortedPositiveIntervals(intervals);
|
||||||
|
if (sorted.isEmpty()) {
|
||||||
|
return new EsperOperatingPeriodEvaluation(List.of(), List.of());
|
||||||
|
}
|
||||||
|
PeriodizationCollector collector = new PeriodizationCollector(operatingSplitIdleThreshold);
|
||||||
|
executeWithRuntime(
|
||||||
|
configuration -> configuration.getCommon().addEventType("OperatingPeriodIntervalInputEvent", EsperOperatingPeriodIntervalInputEvent.class),
|
||||||
|
INPUT_STREAM_EPL,
|
||||||
|
"operatingPeriodIntervalStream",
|
||||||
|
newData -> collectInputIntervals(newData, collector),
|
||||||
|
runtime -> {
|
||||||
|
for (ActivityIntervalDto interval : sorted) {
|
||||||
|
runtime.getEventService().sendEventBean(toInputEvent(interval), "OperatingPeriodIntervalInputEvent");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return collector.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeWithRuntime(
|
||||||
|
java.util.function.Consumer<Configuration> configurationSetup,
|
||||||
|
String epl,
|
||||||
|
String statementName,
|
||||||
|
java.util.function.Consumer<EventBean[]> listener,
|
||||||
|
java.util.function.Consumer<EPRuntime> sender
|
||||||
|
) {
|
||||||
|
EPRuntime runtime = null;
|
||||||
|
try {
|
||||||
|
Configuration configuration = new Configuration();
|
||||||
|
configurationSetup.accept(configuration);
|
||||||
|
String runtimeUri = "eventhub-esper-operating-period-" + RUNTIME_COUNTER.incrementAndGet();
|
||||||
|
runtime = EPRuntimeProvider.getRuntime(runtimeUri, configuration);
|
||||||
|
|
||||||
|
CompilerArguments arguments = new CompilerArguments(configuration);
|
||||||
|
EPCompiled compiled = EPCompilerProvider.getCompiler().compile(epl, arguments);
|
||||||
|
EPDeployment deployment = runtime.getDeploymentService().deploy(compiled);
|
||||||
|
runtime.getDeploymentService()
|
||||||
|
.getStatement(deployment.getDeploymentId(), statementName)
|
||||||
|
.addListener((newData, oldData, statement, rt) -> listener.accept(newData));
|
||||||
|
sender.accept(runtime);
|
||||||
|
} catch (EPCompileException | EPDeployException e) {
|
||||||
|
throw new IllegalStateException("Cannot compile/deploy Esper operating-period EPL", e);
|
||||||
|
} finally {
|
||||||
|
if (runtime != null) {
|
||||||
|
runtime.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void collectInputIntervals(EventBean[] newData, PeriodizationCollector collector) {
|
||||||
|
if (newData == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (EventBean event : newData) {
|
||||||
|
collector.accept((EsperOperatingPeriodIntervalInputEvent) event.getUnderlying());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private EsperOperatingPeriodIntervalInputEvent toInputEvent(ActivityIntervalDto interval) {
|
||||||
|
return new EsperOperatingPeriodIntervalInputEvent(
|
||||||
|
interval.driverEntityId(),
|
||||||
|
interval.vehicleId(),
|
||||||
|
interval.vehicleRegistrationId(),
|
||||||
|
interval.activityType(),
|
||||||
|
interval.cardSlot(),
|
||||||
|
interval.cardStatus(),
|
||||||
|
interval.drivingStatus(),
|
||||||
|
interval.sourceKind(),
|
||||||
|
interval.startedAt().toInstant().toEpochMilli(),
|
||||||
|
interval.endedAt().toInstant().toEpochMilli(),
|
||||||
|
interval.durationSeconds() * 1000L,
|
||||||
|
interval.sourceRowId(),
|
||||||
|
interval.sourceRowIds(),
|
||||||
|
interval.clippedToRequestedPeriod(),
|
||||||
|
"UNKNOWN_GAP".equals(interval.level())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ActivityIntervalDto> sortedPositiveIntervals(List<ActivityIntervalDto> intervals) {
|
||||||
|
if (intervals == null || intervals.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return intervals.stream()
|
||||||
|
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
|
||||||
|
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::endedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::activityType, Comparator.nullsLast(String::compareTo)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record EsperOperatingPeriodEvaluation(
|
||||||
|
List<OperatingPeriodActivityIntervalDto> periodizedIntervals,
|
||||||
|
List<EsperClosedOperatingPeriod> closedPeriods
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class PeriodizationCollector {
|
||||||
|
private final Duration operatingSplitIdleThreshold;
|
||||||
|
private final List<OperatingPeriodActivityIntervalDto> periodizedIntervals = new ArrayList<>();
|
||||||
|
private final List<EsperClosedOperatingPeriod> closedPeriods = new ArrayList<>();
|
||||||
|
private boolean hasOpenPeriod;
|
||||||
|
private long operatingPeriodNo;
|
||||||
|
private OffsetDateTime operatingPeriodStartedAt;
|
||||||
|
private OffsetDateTime lastKnownActivityEndAt;
|
||||||
|
|
||||||
|
private PeriodizationCollector(Duration operatingSplitIdleThreshold) {
|
||||||
|
this.operatingSplitIdleThreshold = operatingSplitIdleThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void accept(EsperOperatingPeriodIntervalInputEvent interval) {
|
||||||
|
ActivityIntervalDto dto = new ActivityIntervalDto(
|
||||||
|
interval.driverId(),
|
||||||
|
interval.vehicleId(),
|
||||||
|
interval.vehicleRegistrationId(),
|
||||||
|
interval.activityType(),
|
||||||
|
interval.cardSlot(),
|
||||||
|
interval.cardStatus(),
|
||||||
|
interval.drivingStatus(),
|
||||||
|
interval.sourceKind(),
|
||||||
|
OffsetDateTime.ofInstant(java.time.Instant.ofEpochMilli(interval.startTs()), java.time.ZoneOffset.UTC),
|
||||||
|
OffsetDateTime.ofInstant(java.time.Instant.ofEpochMilli(interval.endTs()), java.time.ZoneOffset.UTC),
|
||||||
|
interval.durationMs() / 1000L,
|
||||||
|
interval.sourceRowId(),
|
||||||
|
interval.sourceRowIds(),
|
||||||
|
interval.clippedToRequestedPeriod(),
|
||||||
|
interval.synthetic() ? "UNKNOWN_GAP" : "RAW_INTERVAL"
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("UNKNOWN".equals(dto.activityType())) {
|
||||||
|
if (!hasOpenPeriod) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dto.durationSeconds() >= operatingSplitIdleThreshold.getSeconds()) {
|
||||||
|
closeCurrent("UNKNOWN_GAP");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
periodizedIntervals.add(OperatingPeriodActivityIntervalDto.periodized(
|
||||||
|
dto,
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
false,
|
||||||
|
Math.max(0, Duration.between(lastKnownActivityEndAt, dto.startedAt()).getSeconds())
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasOpenPeriod) {
|
||||||
|
operatingPeriodNo = operatingPeriodNo < 1 ? 1 : operatingPeriodNo + 1;
|
||||||
|
hasOpenPeriod = true;
|
||||||
|
operatingPeriodStartedAt = dto.startedAt();
|
||||||
|
lastKnownActivityEndAt = dto.endedAt();
|
||||||
|
periodizedIntervals.add(OperatingPeriodActivityIntervalDto.periodized(
|
||||||
|
dto,
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
true,
|
||||||
|
null
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long gapSeconds = Math.max(0, Duration.between(lastKnownActivityEndAt, dto.startedAt()).getSeconds());
|
||||||
|
if (gapSeconds >= operatingSplitIdleThreshold.getSeconds()) {
|
||||||
|
closeCurrent("IDLE_GAP");
|
||||||
|
operatingPeriodNo++;
|
||||||
|
hasOpenPeriod = true;
|
||||||
|
operatingPeriodStartedAt = dto.startedAt();
|
||||||
|
lastKnownActivityEndAt = dto.endedAt();
|
||||||
|
periodizedIntervals.add(OperatingPeriodActivityIntervalDto.periodized(
|
||||||
|
dto,
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
true,
|
||||||
|
gapSeconds
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
periodizedIntervals.add(OperatingPeriodActivityIntervalDto.periodized(
|
||||||
|
dto,
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
false,
|
||||||
|
gapSeconds
|
||||||
|
));
|
||||||
|
if (dto.endedAt().isAfter(lastKnownActivityEndAt)) {
|
||||||
|
lastKnownActivityEndAt = dto.endedAt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private EsperOperatingPeriodEvaluation finish() {
|
||||||
|
if (hasOpenPeriod) {
|
||||||
|
closeCurrent("FLUSH");
|
||||||
|
}
|
||||||
|
return new EsperOperatingPeriodEvaluation(
|
||||||
|
periodizedIntervals.stream()
|
||||||
|
.sorted(Comparator.comparing(OperatingPeriodActivityIntervalDto::startedAt)
|
||||||
|
.thenComparing(OperatingPeriodActivityIntervalDto::endedAt)
|
||||||
|
.thenComparing(OperatingPeriodActivityIntervalDto::activityType, Comparator.nullsLast(String::compareTo)))
|
||||||
|
.toList(),
|
||||||
|
closedPeriods.stream()
|
||||||
|
.sorted(Comparator.comparing(EsperClosedOperatingPeriod::startedAt))
|
||||||
|
.toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeCurrent(String closedBy) {
|
||||||
|
if (!hasOpenPeriod || operatingPeriodStartedAt == null || lastKnownActivityEndAt == null) {
|
||||||
|
hasOpenPeriod = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closedPeriods.add(new EsperClosedOperatingPeriod(
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
lastKnownActivityEndAt,
|
||||||
|
Duration.between(operatingPeriodStartedAt, lastKnownActivityEndAt).getSeconds(),
|
||||||
|
closedBy
|
||||||
|
));
|
||||||
|
hasOpenPeriod = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,694 @@
|
||||||
|
package at.procon.eventhub.esperpoc.service;
|
||||||
|
|
||||||
|
import at.procon.eventhub.config.EventHubProperties;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodRequest;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperOperatingPeriodResultDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.NonDrivingIntervalDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.OperatingPeriodActivityIntervalDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.OperatingPeriodDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.RawActivityEventDto;
|
||||||
|
import at.procon.eventhub.esperpoc.persistence.EsperPocActivityRepository;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class EsperOperatingPeriodEvaluationService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(EsperOperatingPeriodEvaluationService.class);
|
||||||
|
|
||||||
|
private final EsperPocActivityRepository activityRepository;
|
||||||
|
private final EsperDriverActivityEngine activityEngine;
|
||||||
|
private final EsperOperatingPeriodEngine operatingPeriodEngine;
|
||||||
|
private final EventHubProperties properties;
|
||||||
|
|
||||||
|
public EsperOperatingPeriodEvaluationService(
|
||||||
|
EsperPocActivityRepository activityRepository,
|
||||||
|
EsperDriverActivityEngine activityEngine,
|
||||||
|
EsperOperatingPeriodEngine operatingPeriodEngine
|
||||||
|
) {
|
||||||
|
this(activityRepository, activityEngine, operatingPeriodEngine, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public EsperOperatingPeriodEvaluationService(
|
||||||
|
EsperPocActivityRepository activityRepository,
|
||||||
|
EsperDriverActivityEngine activityEngine,
|
||||||
|
EsperOperatingPeriodEngine operatingPeriodEngine,
|
||||||
|
EventHubProperties properties
|
||||||
|
) {
|
||||||
|
this.activityRepository = activityRepository;
|
||||||
|
this.activityEngine = activityEngine;
|
||||||
|
this.operatingPeriodEngine = operatingPeriodEngine;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EsperOperatingPeriodResultDto evaluate(EsperOperatingPeriodRequest request) {
|
||||||
|
long startedNanos = System.nanoTime();
|
||||||
|
OffsetDateTime requestedFrom = utc(request.occurredFrom());
|
||||||
|
OffsetDateTime requestedTo = utc(request.occurredTo());
|
||||||
|
OffsetDateTime loadedFrom = requestedFrom.minusHours(request.guardHours());
|
||||||
|
OffsetDateTime loadedTo = requestedTo.plusHours(request.guardHours());
|
||||||
|
Duration splitIdleThreshold = Duration.ofHours(resolveOperatingSplitIdleHours(request));
|
||||||
|
Duration significantDrivingThreshold = Duration.ofMinutes(resolveSignificantDrivingMinutes(request));
|
||||||
|
Duration mergeGapTolerance = Duration.ofSeconds(resolveMergeGapSeconds(request));
|
||||||
|
Duration gapDetectionTolerance = Duration.ofSeconds(resolveGapDetectionToleranceSeconds(request));
|
||||||
|
EsperUnknownTreatmentMode unknownTreatmentMode = resolveUnknownTreatmentMode(request);
|
||||||
|
|
||||||
|
long dbStartedNanos = System.nanoTime();
|
||||||
|
List<RawActivityEventDto> rawEvents = activityRepository.findDriverActivityEvents(
|
||||||
|
request.tenantKey(),
|
||||||
|
request.driverId(),
|
||||||
|
loadedFrom,
|
||||||
|
loadedTo
|
||||||
|
);
|
||||||
|
long dbElapsedMs = elapsedMillis(dbStartedNanos);
|
||||||
|
List<RawActivityEventDto> driverCardRawEvents = rawEvents.stream()
|
||||||
|
.filter(event -> "DRIVER_CARD".equals(event.sourceKind()))
|
||||||
|
.toList();
|
||||||
|
List<RawActivityEventDto> vehicleUnitRawEvents = rawEvents.stream()
|
||||||
|
.filter(event -> "VEHICLE_UNIT".equals(event.sourceKind()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
long cardIntervalsStartedNanos = System.nanoTime();
|
||||||
|
List<ActivityIntervalDto> driverCardRawIntervals = activityEngine.buildIntervals(driverCardRawEvents);
|
||||||
|
long cardIntervalsElapsedMs = elapsedMillis(cardIntervalsStartedNanos);
|
||||||
|
long vuIntervalsStartedNanos = System.nanoTime();
|
||||||
|
List<ActivityIntervalDto> vehicleUnitRawIntervals = activityEngine.buildIntervals(vehicleUnitRawEvents);
|
||||||
|
long vuIntervalsElapsedMs = elapsedMillis(vuIntervalsStartedNanos);
|
||||||
|
|
||||||
|
long vuGapFillStartedNanos = System.nanoTime();
|
||||||
|
List<ActivityIntervalDto> resolvedKnownLoadedIntervals = resolveVuFillGaps(driverCardRawIntervals, vehicleUnitRawIntervals);
|
||||||
|
long vuGapFillElapsedMs = elapsedMillis(vuGapFillStartedNanos);
|
||||||
|
|
||||||
|
long unknownGapStartedNanos = System.nanoTime();
|
||||||
|
List<ActivityIntervalDto> evaluationLoadedIntervals = synthesizeUnknownGaps(
|
||||||
|
resolvedKnownLoadedIntervals,
|
||||||
|
gapDetectionTolerance
|
||||||
|
);
|
||||||
|
long unknownGapElapsedMs = elapsedMillis(unknownGapStartedNanos);
|
||||||
|
|
||||||
|
long periodizeStartedNanos = System.nanoTime();
|
||||||
|
EsperOperatingPeriodEngine.EsperOperatingPeriodEvaluation evaluation = operatingPeriodEngine.evaluate(
|
||||||
|
evaluationLoadedIntervals,
|
||||||
|
splitIdleThreshold
|
||||||
|
);
|
||||||
|
long periodizeElapsedMs = elapsedMillis(periodizeStartedNanos);
|
||||||
|
|
||||||
|
long mergeStartedNanos = System.nanoTime();
|
||||||
|
List<OperatingPeriodActivityIntervalDto> mergedLoadedIntervals = mergeConsecutiveActivities(
|
||||||
|
evaluation.periodizedIntervals(),
|
||||||
|
mergeGapTolerance
|
||||||
|
);
|
||||||
|
long mergeElapsedMs = elapsedMillis(mergeStartedNanos);
|
||||||
|
|
||||||
|
long nonDrivingStartedNanos = System.nanoTime();
|
||||||
|
List<NonDrivingIntervalDto> nonDrivingLoadedIntervals = buildNonDrivingIntervals(
|
||||||
|
mergedLoadedIntervals,
|
||||||
|
significantDrivingThreshold,
|
||||||
|
unknownTreatmentMode
|
||||||
|
);
|
||||||
|
long nonDrivingElapsedMs = elapsedMillis(nonDrivingStartedNanos);
|
||||||
|
|
||||||
|
List<OperatingPeriodActivityIntervalDto> periodizedIntervals = clipOperatingIntervals(
|
||||||
|
evaluation.periodizedIntervals(),
|
||||||
|
requestedFrom,
|
||||||
|
requestedTo
|
||||||
|
);
|
||||||
|
List<OperatingPeriodActivityIntervalDto> mergedIntervals = clipOperatingIntervals(
|
||||||
|
mergedLoadedIntervals,
|
||||||
|
requestedFrom,
|
||||||
|
requestedTo
|
||||||
|
);
|
||||||
|
List<NonDrivingIntervalDto> nonDrivingIntervals = clipNonDrivingIntervals(
|
||||||
|
nonDrivingLoadedIntervals,
|
||||||
|
requestedFrom,
|
||||||
|
requestedTo
|
||||||
|
);
|
||||||
|
List<OperatingPeriodDto> operatingPeriods = buildOperatingPeriods(
|
||||||
|
evaluation.closedPeriods(),
|
||||||
|
periodizedIntervals,
|
||||||
|
requestedFrom,
|
||||||
|
requestedTo
|
||||||
|
);
|
||||||
|
long totalElapsedMs = elapsedMillis(startedNanos);
|
||||||
|
|
||||||
|
log.info("Esper operating-period evaluation tenant={} driverId={} requestedFrom={} requestedTo={} loadedFrom={} loadedTo={} unknownMode={} rawEvents={} cardRawEvents={} vuRawEvents={} cardIntervals={} vuIntervals={} resolvedKnownIntervals={} evaluationIntervals={} periodizedIntervals={} mergedIntervals={} nonDrivingIntervals={} operatingPeriods={} timingsMs={{dbRetrieve={}, cardIntervalEsper={}, vuIntervalEsper={}, vuGapFill={}, synthUnknown={}, periodizeEsper={}, merge={}, nonDriving={}, total={}}}",
|
||||||
|
request.tenantKey(),
|
||||||
|
request.driverId(),
|
||||||
|
requestedFrom,
|
||||||
|
requestedTo,
|
||||||
|
loadedFrom,
|
||||||
|
loadedTo,
|
||||||
|
unknownTreatmentMode,
|
||||||
|
rawEvents.size(),
|
||||||
|
driverCardRawEvents.size(),
|
||||||
|
vehicleUnitRawEvents.size(),
|
||||||
|
driverCardRawIntervals.size(),
|
||||||
|
vehicleUnitRawIntervals.size(),
|
||||||
|
resolvedKnownLoadedIntervals.size(),
|
||||||
|
evaluationLoadedIntervals.size(),
|
||||||
|
periodizedIntervals.size(),
|
||||||
|
mergedIntervals.size(),
|
||||||
|
nonDrivingIntervals.size(),
|
||||||
|
operatingPeriods.size(),
|
||||||
|
dbElapsedMs,
|
||||||
|
cardIntervalsElapsedMs,
|
||||||
|
vuIntervalsElapsedMs,
|
||||||
|
vuGapFillElapsedMs,
|
||||||
|
unknownGapElapsedMs,
|
||||||
|
periodizeElapsedMs,
|
||||||
|
mergeElapsedMs,
|
||||||
|
nonDrivingElapsedMs,
|
||||||
|
totalElapsedMs);
|
||||||
|
|
||||||
|
return new EsperOperatingPeriodResultDto(
|
||||||
|
request.tenantKey(),
|
||||||
|
request.driverId(),
|
||||||
|
requestedFrom,
|
||||||
|
requestedTo,
|
||||||
|
loadedFrom,
|
||||||
|
loadedTo,
|
||||||
|
rawEvents.size(),
|
||||||
|
driverCardRawEvents.size(),
|
||||||
|
vehicleUnitRawEvents.size(),
|
||||||
|
driverCardRawIntervals.size(),
|
||||||
|
vehicleUnitRawIntervals.size(),
|
||||||
|
resolvedKnownLoadedIntervals.size(),
|
||||||
|
evaluationLoadedIntervals.size(),
|
||||||
|
periodizedIntervals.size(),
|
||||||
|
mergedIntervals.size(),
|
||||||
|
nonDrivingIntervals.size(),
|
||||||
|
operatingPeriods.size(),
|
||||||
|
resolveOperatingSplitIdleHours(request),
|
||||||
|
resolveSignificantDrivingMinutes(request),
|
||||||
|
resolveMergeGapSeconds(request),
|
||||||
|
resolveGapDetectionToleranceSeconds(request),
|
||||||
|
unknownTreatmentMode,
|
||||||
|
rawEvents,
|
||||||
|
resolvedKnownLoadedIntervals,
|
||||||
|
evaluationLoadedIntervals,
|
||||||
|
periodizedIntervals,
|
||||||
|
mergedIntervals,
|
||||||
|
nonDrivingIntervals,
|
||||||
|
operatingPeriods,
|
||||||
|
notes(
|
||||||
|
unknownTreatmentMode,
|
||||||
|
resolveOperatingSplitIdleHours(request),
|
||||||
|
resolveSignificantDrivingMinutes(request),
|
||||||
|
resolveGapDetectionToleranceSeconds(request)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ActivityIntervalDto> synthesizeUnknownGaps(
|
||||||
|
List<ActivityIntervalDto> resolvedKnownLoadedIntervals,
|
||||||
|
Duration gapDetectionTolerance
|
||||||
|
) {
|
||||||
|
List<ActivityIntervalDto> allKnown = sortedPositiveIntervals(resolvedKnownLoadedIntervals);
|
||||||
|
List<ActivityIntervalDto> nonRestActivities = allKnown.stream()
|
||||||
|
.filter(interval -> !"BREAK_REST".equals(interval.activityType()))
|
||||||
|
.toList();
|
||||||
|
if (nonRestActivities.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ActivityIntervalDto> result = new ArrayList<>();
|
||||||
|
for (int index = 0; index < nonRestActivities.size(); index++) {
|
||||||
|
ActivityIntervalDto current = nonRestActivities.get(index);
|
||||||
|
result.add(current);
|
||||||
|
if (index + 1 >= nonRestActivities.size()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ActivityIntervalDto next = nonRestActivities.get(index + 1);
|
||||||
|
if (!next.startedAt().isAfter(current.endedAt())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
OffsetDateTime gapStart = current.endedAt();
|
||||||
|
OffsetDateTime gapEnd = next.startedAt();
|
||||||
|
List<ActivityIntervalDto> uncoveredGapSegments = subtractCoverage(
|
||||||
|
unknownGapTemplate(current, next, gapStart, gapEnd),
|
||||||
|
allKnown
|
||||||
|
);
|
||||||
|
for (ActivityIntervalDto gap : uncoveredGapSegments) {
|
||||||
|
if (gap.durationSeconds() > gapDetectionTolerance.getSeconds()) {
|
||||||
|
result.add(gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.stream()
|
||||||
|
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::endedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::activityType, Comparator.nullsLast(String::compareTo)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<OperatingPeriodActivityIntervalDto> mergeConsecutiveActivities(
|
||||||
|
List<OperatingPeriodActivityIntervalDto> intervals,
|
||||||
|
Duration mergeGapTolerance
|
||||||
|
) {
|
||||||
|
if (intervals == null || intervals.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<OperatingPeriodActivityIntervalDto> sorted = intervals.stream()
|
||||||
|
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
|
||||||
|
.sorted(Comparator.comparing(OperatingPeriodActivityIntervalDto::startedAt)
|
||||||
|
.thenComparing(OperatingPeriodActivityIntervalDto::endedAt)
|
||||||
|
.thenComparing(OperatingPeriodActivityIntervalDto::activityType, Comparator.nullsLast(String::compareTo)))
|
||||||
|
.toList();
|
||||||
|
List<OperatingPeriodActivityIntervalDto> result = new ArrayList<>();
|
||||||
|
OperatingPeriodActivityIntervalDto current = null;
|
||||||
|
List<String> currentSources = new ArrayList<>();
|
||||||
|
for (OperatingPeriodActivityIntervalDto next : sorted) {
|
||||||
|
if (current == null) {
|
||||||
|
current = next;
|
||||||
|
currentSources = new ArrayList<>(next.sourceRowIds());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (canMerge(current, next, mergeGapTolerance)) {
|
||||||
|
currentSources.addAll(next.sourceRowIds());
|
||||||
|
current = current.asMerged(max(current.endedAt(), next.endedAt()), List.copyOf(currentSources));
|
||||||
|
} else {
|
||||||
|
result.add(current.asMerged(current.endedAt(), List.copyOf(currentSources)));
|
||||||
|
current = next;
|
||||||
|
currentSources = new ArrayList<>(next.sourceRowIds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (current != null) {
|
||||||
|
result.add(current.asMerged(current.endedAt(), List.copyOf(currentSources)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NonDrivingIntervalDto> buildNonDrivingIntervals(
|
||||||
|
List<OperatingPeriodActivityIntervalDto> mergedIntervals,
|
||||||
|
Duration significantDrivingThreshold,
|
||||||
|
EsperUnknownTreatmentMode unknownTreatmentMode
|
||||||
|
) {
|
||||||
|
if (mergedIntervals == null || mergedIntervals.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<NonDrivingIntervalDto> result = new ArrayList<>();
|
||||||
|
NonDrivingAccumulator open = null;
|
||||||
|
for (OperatingPeriodActivityIntervalDto interval : mergedIntervals) {
|
||||||
|
if (open != null && open.operatingPeriodNo != interval.operatingPeriodNo()) {
|
||||||
|
result.add(open.close("NEW_OPERATING_PERIOD"));
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
if (startsOrExtendsNonDriving(interval, unknownTreatmentMode)) {
|
||||||
|
open = open == null ? NonDrivingAccumulator.open(interval) : open.extend(interval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("UNKNOWN".equals(interval.activityType()) && unknownTreatmentMode == EsperUnknownTreatmentMode.AS_UNKNOWN_NON_BREAK) {
|
||||||
|
if (open != null) {
|
||||||
|
result.add(open.close("UNKNOWN_GAP"));
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("DRIVE".equals(interval.activityType())) {
|
||||||
|
if (interval.durationSeconds() >= significantDrivingThreshold.getSeconds()) {
|
||||||
|
if (open != null) {
|
||||||
|
result.add(open.close("SIGNIFICANT_DRIVING"));
|
||||||
|
open = null;
|
||||||
|
}
|
||||||
|
} else if (open != null) {
|
||||||
|
open = open.extend(interval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open != null) {
|
||||||
|
result.add(open.close("FLUSH"));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean startsOrExtendsNonDriving(
|
||||||
|
OperatingPeriodActivityIntervalDto interval,
|
||||||
|
EsperUnknownTreatmentMode unknownTreatmentMode
|
||||||
|
) {
|
||||||
|
if ("WORK".equals(interval.activityType()) || "AVAILABILITY".equals(interval.activityType())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return "UNKNOWN".equals(interval.activityType())
|
||||||
|
&& unknownTreatmentMode == EsperUnknownTreatmentMode.AS_BREAK_REST;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean canMerge(
|
||||||
|
OperatingPeriodActivityIntervalDto left,
|
||||||
|
OperatingPeriodActivityIntervalDto right,
|
||||||
|
Duration mergeGapTolerance
|
||||||
|
) {
|
||||||
|
long gapSeconds = Duration.between(left.endedAt(), right.startedAt()).getSeconds();
|
||||||
|
return Objects.equals(left.driverId(), right.driverId())
|
||||||
|
&& left.operatingPeriodNo() == right.operatingPeriodNo()
|
||||||
|
&& Objects.equals(left.activityType(), right.activityType())
|
||||||
|
&& Objects.equals(left.cardSlot(), right.cardSlot())
|
||||||
|
&& Objects.equals(left.cardStatus(), right.cardStatus())
|
||||||
|
&& Objects.equals(left.drivingStatus(), right.drivingStatus())
|
||||||
|
&& Objects.equals(left.sourceKind(), right.sourceKind())
|
||||||
|
&& left.synthetic() == right.synthetic()
|
||||||
|
&& gapSeconds <= mergeGapTolerance.getSeconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OperatingPeriodDto> buildOperatingPeriods(
|
||||||
|
List<EsperClosedOperatingPeriod> closedPeriods,
|
||||||
|
List<OperatingPeriodActivityIntervalDto> clippedPeriodizedIntervals,
|
||||||
|
OffsetDateTime requestedFrom,
|
||||||
|
OffsetDateTime requestedTo
|
||||||
|
) {
|
||||||
|
Map<Long, List<OperatingPeriodActivityIntervalDto>> intervalsByPeriod = new LinkedHashMap<>();
|
||||||
|
for (OperatingPeriodActivityIntervalDto interval : clippedPeriodizedIntervals) {
|
||||||
|
intervalsByPeriod.computeIfAbsent(interval.operatingPeriodNo(), ignored -> new ArrayList<>()).add(interval);
|
||||||
|
}
|
||||||
|
List<OperatingPeriodDto> result = new ArrayList<>();
|
||||||
|
for (EsperClosedOperatingPeriod closedPeriod : closedPeriods) {
|
||||||
|
if (!closedPeriod.endedAt().isAfter(requestedFrom) || !closedPeriod.startedAt().isBefore(requestedTo)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
OffsetDateTime start = max(closedPeriod.startedAt(), requestedFrom);
|
||||||
|
OffsetDateTime end = min(closedPeriod.endedAt(), requestedTo);
|
||||||
|
if (!end.isAfter(start)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<OperatingPeriodActivityIntervalDto> intervals = intervalsByPeriod.getOrDefault(closedPeriod.operatingPeriodNo(), List.of());
|
||||||
|
long drivingSeconds = sumActivitySeconds(intervals, "DRIVE");
|
||||||
|
long workSeconds = sumActivitySeconds(intervals, "WORK");
|
||||||
|
long availabilitySeconds = sumActivitySeconds(intervals, "AVAILABILITY");
|
||||||
|
long unknownSeconds = sumActivitySeconds(intervals, "UNKNOWN");
|
||||||
|
result.add(new OperatingPeriodDto(
|
||||||
|
closedPeriod.operatingPeriodNo(),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
Duration.between(start, end).getSeconds(),
|
||||||
|
closedPeriod.closedBy(),
|
||||||
|
drivingSeconds,
|
||||||
|
workSeconds,
|
||||||
|
availabilitySeconds,
|
||||||
|
unknownSeconds,
|
||||||
|
intervals.size(),
|
||||||
|
!start.equals(closedPeriod.startedAt()) || !end.equals(closedPeriod.endedAt())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long sumActivitySeconds(List<OperatingPeriodActivityIntervalDto> intervals, String activityType) {
|
||||||
|
return intervals.stream()
|
||||||
|
.filter(interval -> activityType.equals(interval.activityType()))
|
||||||
|
.mapToLong(OperatingPeriodActivityIntervalDto::durationSeconds)
|
||||||
|
.sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OperatingPeriodActivityIntervalDto> clipOperatingIntervals(
|
||||||
|
List<OperatingPeriodActivityIntervalDto> intervals,
|
||||||
|
OffsetDateTime requestedFrom,
|
||||||
|
OffsetDateTime requestedTo
|
||||||
|
) {
|
||||||
|
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 interval.withTime(start, end, clipped);
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<NonDrivingIntervalDto> clipNonDrivingIntervals(
|
||||||
|
List<NonDrivingIntervalDto> intervals,
|
||||||
|
OffsetDateTime requestedFrom,
|
||||||
|
OffsetDateTime requestedTo
|
||||||
|
) {
|
||||||
|
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 NonDrivingIntervalDto(
|
||||||
|
interval.driverId(),
|
||||||
|
interval.vehicleId(),
|
||||||
|
interval.vehicleRegistrationId(),
|
||||||
|
interval.cardSlot(),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
Duration.between(start, end).getSeconds(),
|
||||||
|
interval.operatingPeriodNo(),
|
||||||
|
interval.operatingPeriodStartedAt(),
|
||||||
|
interval.closedBy(),
|
||||||
|
interval.containsUnknown(),
|
||||||
|
interval.unknownSeconds(),
|
||||||
|
!start.equals(interval.startedAt()) || !end.equals(interval.endedAt())
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityIntervalDto unknownGapTemplate(
|
||||||
|
ActivityIntervalDto previous,
|
||||||
|
ActivityIntervalDto next,
|
||||||
|
OffsetDateTime gapStart,
|
||||||
|
OffsetDateTime gapEnd
|
||||||
|
) {
|
||||||
|
return new ActivityIntervalDto(
|
||||||
|
previous.driverEntityId(),
|
||||||
|
Objects.equals(previous.vehicleId(), next.vehicleId()) ? previous.vehicleId() : null,
|
||||||
|
Objects.equals(previous.vehicleRegistrationId(), next.vehicleRegistrationId()) ? previous.vehicleRegistrationId() : null,
|
||||||
|
"UNKNOWN",
|
||||||
|
Objects.equals(previous.cardSlot(), next.cardSlot()) ? previous.cardSlot() : null,
|
||||||
|
previous.cardStatus(),
|
||||||
|
"UNKNOWN",
|
||||||
|
"SYNTHETIC_GAP",
|
||||||
|
gapStart,
|
||||||
|
gapEnd,
|
||||||
|
Duration.between(gapStart, gapEnd).getSeconds(),
|
||||||
|
null,
|
||||||
|
List.of(),
|
||||||
|
false,
|
||||||
|
"UNKNOWN_GAP"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ActivityIntervalDto> resolveVuFillGaps(
|
||||||
|
List<ActivityIntervalDto> driverCardRawIntervals,
|
||||||
|
List<ActivityIntervalDto> vehicleUnitRawIntervals
|
||||||
|
) {
|
||||||
|
List<ActivityIntervalDto> driverCard = sortedPositiveIntervals(driverCardRawIntervals);
|
||||||
|
List<ActivityIntervalDto> vehicleUnit = sortedPositiveIntervals(vehicleUnitRawIntervals);
|
||||||
|
if (driverCard.isEmpty()) {
|
||||||
|
return vehicleUnit;
|
||||||
|
}
|
||||||
|
if (vehicleUnit.isEmpty()) {
|
||||||
|
return driverCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ActivityIntervalDto> resolved = new ArrayList<>(driverCard);
|
||||||
|
for (ActivityIntervalDto vuInterval : vehicleUnit) {
|
||||||
|
resolved.addAll(subtractCoverage(vuInterval, driverCard));
|
||||||
|
}
|
||||||
|
return resolved.stream()
|
||||||
|
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::endedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::sourceKind, Comparator.nullsLast(String::compareTo)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ActivityIntervalDto> subtractCoverage(
|
||||||
|
ActivityIntervalDto candidate,
|
||||||
|
List<ActivityIntervalDto> coverage
|
||||||
|
) {
|
||||||
|
List<ActivityIntervalDto> result = new ArrayList<>();
|
||||||
|
OffsetDateTime cursor = candidate.startedAt();
|
||||||
|
for (ActivityIntervalDto covered : coverage) {
|
||||||
|
if (!covered.endedAt().isAfter(cursor)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!covered.startedAt().isBefore(candidate.endedAt())) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
OffsetDateTime overlapStart = max(cursor, covered.startedAt());
|
||||||
|
if (overlapStart.isAfter(cursor)) {
|
||||||
|
result.add(candidate.withTime(cursor, overlapStart, candidate.clippedToRequestedPeriod()));
|
||||||
|
}
|
||||||
|
if (covered.endedAt().isAfter(cursor)) {
|
||||||
|
cursor = max(cursor, covered.endedAt());
|
||||||
|
}
|
||||||
|
if (!candidate.endedAt().isAfter(cursor)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.endedAt().isAfter(cursor)) {
|
||||||
|
result.add(candidate.withTime(cursor, candidate.endedAt(), candidate.clippedToRequestedPeriod()));
|
||||||
|
}
|
||||||
|
return result.stream()
|
||||||
|
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ActivityIntervalDto> sortedPositiveIntervals(List<ActivityIntervalDto> intervals) {
|
||||||
|
if (intervals == null || intervals.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return intervals.stream()
|
||||||
|
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
|
||||||
|
.sorted(Comparator.comparing(ActivityIntervalDto::startedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::endedAt)
|
||||||
|
.thenComparing(ActivityIntervalDto::activityType, Comparator.nullsLast(String::compareTo)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveOperatingSplitIdleHours(EsperOperatingPeriodRequest request) {
|
||||||
|
if (request.operatingSplitIdleHours() != null) {
|
||||||
|
return request.operatingSplitIdleHours();
|
||||||
|
}
|
||||||
|
return properties == null ? 7 : properties.getEsperPoc().getOperatingPeriodEvaluation().getOperatingSplitIdleHours();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveSignificantDrivingMinutes(EsperOperatingPeriodRequest request) {
|
||||||
|
if (request.significantDrivingMinutes() != null) {
|
||||||
|
return request.significantDrivingMinutes();
|
||||||
|
}
|
||||||
|
return properties == null ? 3 : properties.getEsperPoc().getOperatingPeriodEvaluation().getSignificantDrivingMinutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveMergeGapSeconds(EsperOperatingPeriodRequest request) {
|
||||||
|
if (request.mergeGapSeconds() != null) {
|
||||||
|
return request.mergeGapSeconds();
|
||||||
|
}
|
||||||
|
return properties == null ? 0 : properties.getEsperPoc().getOperatingPeriodEvaluation().getMergeGapSeconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int resolveGapDetectionToleranceSeconds(EsperOperatingPeriodRequest request) {
|
||||||
|
if (request.gapDetectionToleranceSeconds() != null) {
|
||||||
|
return request.gapDetectionToleranceSeconds();
|
||||||
|
}
|
||||||
|
return properties == null ? 0 : properties.getEsperPoc().getOperatingPeriodEvaluation().getGapDetectionToleranceSeconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
private EsperUnknownTreatmentMode resolveUnknownTreatmentMode(EsperOperatingPeriodRequest request) {
|
||||||
|
if (request.unknownTreatmentMode() != null) {
|
||||||
|
return request.unknownTreatmentMode();
|
||||||
|
}
|
||||||
|
return properties == null
|
||||||
|
? EsperUnknownTreatmentMode.AS_BREAK_REST
|
||||||
|
: properties.getEsperPoc().getOperatingPeriodEvaluation().getUnknownTreatmentMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> notes(
|
||||||
|
EsperUnknownTreatmentMode unknownTreatmentMode,
|
||||||
|
int operatingSplitIdleHours,
|
||||||
|
int significantDrivingMinutes,
|
||||||
|
int gapDetectionToleranceSeconds
|
||||||
|
) {
|
||||||
|
return List.of(
|
||||||
|
"This endpoint runs in parallel to the existing working-shift PoC and does not change its semantics.",
|
||||||
|
"BREAK_REST events are ignored for activity evaluation but still prevent synthetic UNKNOWN intervals from being created over covered rest spans.",
|
||||||
|
"Synthetic UNKNOWN intervals are created only for uncovered gaps between non-rest activities.",
|
||||||
|
"UNKNOWN treatment mode is " + unknownTreatmentMode + ".",
|
||||||
|
"Operating periods split after " + operatingSplitIdleHours + " hours of no non-rest activity; significant driving closes non-driving intervals from " + significantDrivingMinutes + " minutes onward.",
|
||||||
|
"Synthetic UNKNOWN gaps are only emitted when uncovered time exceeds " + gapDetectionToleranceSeconds + " seconds."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime utc(OffsetDateTime value) {
|
||||||
|
return value == null ? null : value.withOffsetSameInstant(java.time.ZoneOffset.UTC);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime max(OffsetDateTime left, OffsetDateTime right) {
|
||||||
|
return left.isAfter(right) ? left : right;
|
||||||
|
}
|
||||||
|
|
||||||
|
private OffsetDateTime min(OffsetDateTime left, OffsetDateTime right) {
|
||||||
|
return left.isBefore(right) ? left : right;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long elapsedMillis(long startedNanos) {
|
||||||
|
return Math.round((System.nanoTime() - startedNanos) / 1_000_000.0d);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class NonDrivingAccumulator {
|
||||||
|
private final UUID driverId;
|
||||||
|
private UUID vehicleId;
|
||||||
|
private UUID vehicleRegistrationId;
|
||||||
|
private final String cardSlot;
|
||||||
|
private final OffsetDateTime startedAt;
|
||||||
|
private OffsetDateTime endedAt;
|
||||||
|
private final long operatingPeriodNo;
|
||||||
|
private final OffsetDateTime operatingPeriodStartedAt;
|
||||||
|
private boolean containsUnknown;
|
||||||
|
private long unknownSeconds;
|
||||||
|
|
||||||
|
private NonDrivingAccumulator(OperatingPeriodActivityIntervalDto interval) {
|
||||||
|
this.driverId = interval.driverId();
|
||||||
|
this.vehicleId = interval.vehicleId();
|
||||||
|
this.vehicleRegistrationId = interval.vehicleRegistrationId();
|
||||||
|
this.cardSlot = interval.cardSlot();
|
||||||
|
this.startedAt = interval.startedAt();
|
||||||
|
this.endedAt = interval.endedAt();
|
||||||
|
this.operatingPeriodNo = interval.operatingPeriodNo();
|
||||||
|
this.operatingPeriodStartedAt = interval.operatingPeriodStartedAt();
|
||||||
|
this.containsUnknown = "UNKNOWN".equals(interval.activityType());
|
||||||
|
this.unknownSeconds = "UNKNOWN".equals(interval.activityType()) ? interval.durationSeconds() : 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static NonDrivingAccumulator open(OperatingPeriodActivityIntervalDto interval) {
|
||||||
|
return new NonDrivingAccumulator(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NonDrivingAccumulator extend(OperatingPeriodActivityIntervalDto interval) {
|
||||||
|
if (interval.vehicleId() != null) {
|
||||||
|
this.vehicleId = interval.vehicleId();
|
||||||
|
}
|
||||||
|
if (interval.vehicleRegistrationId() != null) {
|
||||||
|
this.vehicleRegistrationId = interval.vehicleRegistrationId();
|
||||||
|
}
|
||||||
|
if (interval.endedAt().isAfter(this.endedAt)) {
|
||||||
|
this.endedAt = interval.endedAt();
|
||||||
|
}
|
||||||
|
if ("UNKNOWN".equals(interval.activityType())) {
|
||||||
|
this.containsUnknown = true;
|
||||||
|
this.unknownSeconds += interval.durationSeconds();
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NonDrivingIntervalDto close(String closedBy) {
|
||||||
|
return new NonDrivingIntervalDto(
|
||||||
|
driverId,
|
||||||
|
vehicleId,
|
||||||
|
vehicleRegistrationId,
|
||||||
|
cardSlot,
|
||||||
|
startedAt,
|
||||||
|
endedAt,
|
||||||
|
Duration.between(startedAt, endedAt).getSeconds(),
|
||||||
|
operatingPeriodNo,
|
||||||
|
operatingPeriodStartedAt,
|
||||||
|
closedBy,
|
||||||
|
containsUnknown,
|
||||||
|
unknownSeconds,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package at.procon.eventhub.esperpoc.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record EsperOperatingPeriodIntervalInputEvent(
|
||||||
|
UUID driverId,
|
||||||
|
UUID vehicleId,
|
||||||
|
UUID vehicleRegistrationId,
|
||||||
|
String activityType,
|
||||||
|
String cardSlot,
|
||||||
|
String cardStatus,
|
||||||
|
String drivingStatus,
|
||||||
|
String sourceKind,
|
||||||
|
long startTs,
|
||||||
|
long endTs,
|
||||||
|
long durationMs,
|
||||||
|
String sourceRowId,
|
||||||
|
List<String> sourceRowIds,
|
||||||
|
boolean clippedToRequestedPeriod,
|
||||||
|
boolean synthetic
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -123,6 +123,12 @@ eventhub:
|
||||||
esper-poc:
|
esper-poc:
|
||||||
activity-merge-mode: JAVA
|
activity-merge-mode: JAVA
|
||||||
shift-resolution-mode: JAVA
|
shift-resolution-mode: JAVA
|
||||||
|
operating-period-evaluation:
|
||||||
|
operating-split-idle-hours: 7
|
||||||
|
significant-driving-minutes: 3
|
||||||
|
merge-gap-seconds: 0
|
||||||
|
gap-detection-tolerance-seconds: 0
|
||||||
|
unknown-treatment-mode: AS_BREAK_REST
|
||||||
|
|
||||||
yellow-fox:
|
yellow-fox:
|
||||||
default-chunk-days: 1
|
default-chunk-days: 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
package at.procon.eventhub.esperpoc.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import at.procon.eventhub.esperpoc.dto.ActivityIntervalDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.EsperUnknownTreatmentMode;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.NonDrivingIntervalDto;
|
||||||
|
import at.procon.eventhub.esperpoc.dto.OperatingPeriodActivityIntervalDto;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class EsperOperatingPeriodEvaluationServiceTest {
|
||||||
|
|
||||||
|
private final EsperOperatingPeriodEngine operatingPeriodEngine = new EsperOperatingPeriodEngine();
|
||||||
|
private final EsperOperatingPeriodEvaluationService service = new EsperOperatingPeriodEvaluationService(
|
||||||
|
null,
|
||||||
|
new EsperDriverActivityEngine(),
|
||||||
|
operatingPeriodEngine
|
||||||
|
);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void synthesizeUnknownGapsSkipsCoveredBreakRestAndCreatesOnlyUncoveredUnknown() {
|
||||||
|
UUID driverId = UUID.randomUUID();
|
||||||
|
List<ActivityIntervalDto> intervals = List.of(
|
||||||
|
activity(driverId, "WORK", "2026-04-01T08:00:00Z", "2026-04-01T09:00:00Z", "w1", "DRIVER_CARD"),
|
||||||
|
activity(driverId, "BREAK_REST", "2026-04-01T09:00:00Z", "2026-04-01T10:00:00Z", "r1", "DRIVER_CARD"),
|
||||||
|
activity(driverId, "AVAILABILITY", "2026-04-01T10:00:00Z", "2026-04-01T11:00:00Z", "a1", "DRIVER_CARD"),
|
||||||
|
activity(driverId, "WORK", "2026-04-01T12:00:00Z", "2026-04-01T13:00:00Z", "w2", "DRIVER_CARD")
|
||||||
|
);
|
||||||
|
|
||||||
|
List<ActivityIntervalDto> evaluationIntervals = service.synthesizeUnknownGaps(intervals, Duration.ZERO);
|
||||||
|
|
||||||
|
assertThat(evaluationIntervals).extracting(ActivityIntervalDto::activityType)
|
||||||
|
.containsExactly("WORK", "AVAILABILITY", "UNKNOWN", "WORK");
|
||||||
|
assertThat(evaluationIntervals.get(2).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T11:00:00Z"));
|
||||||
|
assertThat(evaluationIntervals.get(2).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T12:00:00Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void periodizationSplitsAfterLongUnknownGapAndShortDrivingDoesNotCloseNonDriving() {
|
||||||
|
UUID driverId = UUID.randomUUID();
|
||||||
|
List<ActivityIntervalDto> evaluationIntervals = List.of(
|
||||||
|
activity(driverId, "WORK", "2026-04-01T08:00:00Z", "2026-04-01T09:00:00Z", "w1", "DRIVER_CARD"),
|
||||||
|
activity(driverId, "AVAILABILITY", "2026-04-01T10:00:00Z", "2026-04-01T11:00:00Z", "a1", "DRIVER_CARD"),
|
||||||
|
activity(driverId, "DRIVE", "2026-04-01T11:00:00Z", "2026-04-01T11:02:00Z", "d1", "DRIVER_CARD"),
|
||||||
|
activity(driverId, "WORK", "2026-04-01T11:02:00Z", "2026-04-01T12:00:00Z", "w2", "DRIVER_CARD"),
|
||||||
|
unknown(driverId, "2026-04-01T12:00:00Z", "2026-04-01T20:00:00Z"),
|
||||||
|
activity(driverId, "DRIVE", "2026-04-01T20:00:00Z", "2026-04-01T20:30:00Z", "d2", "DRIVER_CARD")
|
||||||
|
);
|
||||||
|
|
||||||
|
EsperOperatingPeriodEngine.EsperOperatingPeriodEvaluation evaluation = operatingPeriodEngine.evaluate(
|
||||||
|
evaluationIntervals,
|
||||||
|
Duration.ofHours(7)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(evaluation.periodizedIntervals()).extracting(OperatingPeriodActivityIntervalDto::activityType)
|
||||||
|
.containsExactly("WORK", "AVAILABILITY", "DRIVE", "WORK", "DRIVE");
|
||||||
|
assertThat(evaluation.closedPeriods()).hasSize(2);
|
||||||
|
assertThat(evaluation.closedPeriods().get(0).closedBy()).isEqualTo("UNKNOWN_GAP");
|
||||||
|
assertThat(evaluation.closedPeriods().get(1).closedBy()).isEqualTo("FLUSH");
|
||||||
|
|
||||||
|
List<OperatingPeriodActivityIntervalDto> mergedIntervals = service.mergeConsecutiveActivities(
|
||||||
|
evaluation.periodizedIntervals(),
|
||||||
|
Duration.ZERO
|
||||||
|
);
|
||||||
|
List<NonDrivingIntervalDto> nonDrivingIntervals = service.buildNonDrivingIntervals(
|
||||||
|
mergedIntervals,
|
||||||
|
Duration.ofMinutes(3),
|
||||||
|
EsperUnknownTreatmentMode.AS_BREAK_REST
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(nonDrivingIntervals).hasSize(1);
|
||||||
|
assertThat(nonDrivingIntervals.get(0).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T08:00:00Z"));
|
||||||
|
assertThat(nonDrivingIntervals.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T12:00:00Z"));
|
||||||
|
assertThat(nonDrivingIntervals.get(0).closedBy()).isEqualTo("NEW_OPERATING_PERIOD");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownCanCloseNonDrivingWhenConfiguredAsNonBreak() {
|
||||||
|
UUID driverId = UUID.randomUUID();
|
||||||
|
List<OperatingPeriodActivityIntervalDto> mergedIntervals = List.of(
|
||||||
|
periodized(activity(driverId, "WORK", "2026-04-01T08:00:00Z", "2026-04-01T09:00:00Z", "w1", "DRIVER_CARD"), 1),
|
||||||
|
periodized(unknown(driverId, "2026-04-01T09:00:00Z", "2026-04-01T09:30:00Z"), 1),
|
||||||
|
periodized(activity(driverId, "AVAILABILITY", "2026-04-01T09:30:00Z", "2026-04-01T10:00:00Z", "a1", "DRIVER_CARD"), 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<NonDrivingIntervalDto> nonDrivingIntervals = service.buildNonDrivingIntervals(
|
||||||
|
mergedIntervals,
|
||||||
|
Duration.ofMinutes(3),
|
||||||
|
EsperUnknownTreatmentMode.AS_UNKNOWN_NON_BREAK
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(nonDrivingIntervals).hasSize(2);
|
||||||
|
assertThat(nonDrivingIntervals.get(0).closedBy()).isEqualTo("UNKNOWN_GAP");
|
||||||
|
assertThat(nonDrivingIntervals.get(0).endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T09:00:00Z"));
|
||||||
|
assertThat(nonDrivingIntervals.get(1).startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T09:30:00Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityIntervalDto activity(
|
||||||
|
UUID driverId,
|
||||||
|
String activity,
|
||||||
|
String from,
|
||||||
|
String to,
|
||||||
|
String sourceRowId,
|
||||||
|
String sourceKind
|
||||||
|
) {
|
||||||
|
return ActivityIntervalDto.raw(
|
||||||
|
driverId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
activity,
|
||||||
|
"DRIVER",
|
||||||
|
"INSERTED",
|
||||||
|
"KNOWN",
|
||||||
|
sourceKind,
|
||||||
|
OffsetDateTime.parse(from),
|
||||||
|
OffsetDateTime.parse(to),
|
||||||
|
sourceRowId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ActivityIntervalDto unknown(UUID driverId, String from, String to) {
|
||||||
|
return new ActivityIntervalDto(
|
||||||
|
driverId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"UNKNOWN",
|
||||||
|
"DRIVER",
|
||||||
|
null,
|
||||||
|
"UNKNOWN",
|
||||||
|
"SYNTHETIC_GAP",
|
||||||
|
OffsetDateTime.parse(from),
|
||||||
|
OffsetDateTime.parse(to),
|
||||||
|
Duration.between(OffsetDateTime.parse(from), OffsetDateTime.parse(to)).getSeconds(),
|
||||||
|
null,
|
||||||
|
List.of(),
|
||||||
|
false,
|
||||||
|
"UNKNOWN_GAP"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OperatingPeriodActivityIntervalDto periodized(ActivityIntervalDto interval, long operatingPeriodNo) {
|
||||||
|
return OperatingPeriodActivityIntervalDto.periodized(
|
||||||
|
interval,
|
||||||
|
operatingPeriodNo,
|
||||||
|
interval.startedAt(),
|
||||||
|
false,
|
||||||
|
0L
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue