Extract VU activity intervals into driver sessions

This commit is contained in:
trifonovt 2026-05-12 15:15:57 +02:00
parent 6b43a4b0e8
commit e30f98b2c0
3 changed files with 345 additions and 12 deletions

View File

@ -12,6 +12,8 @@ import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
@ -19,6 +21,7 @@ import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.UUID;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
@ -51,14 +54,7 @@ public class VehicleUnitXmlExtractionService {
VehicleContext vehicleContext = extractVehicleContext(document, sessionWarnings);
Map<String, DriverExtractionBuilder> driversByKey = new LinkedHashMap<>();
if (nodes(document, "/VehicleUnit/Activities/vuActivityDailyData").getLength() > 0) {
sessionWarnings.add(new ExtractionWarning(
"VU_ACTIVITY_NOT_EXTRACTED",
"Vehicle-unit daily activity data is present but is not yet mapped into card activity intervals in this phase.",
"/VehicleUnit/Activities/vuActivityDailyData"
));
}
List<VuCardIwInterval> vuCardIwIntervals = new ArrayList<>();
NodeList records = nodes(document, "/VehicleUnit/Activities/vuCardIWData/vuCardIWRecords");
for (int i = 0; i < records.getLength(); i++) {
@ -135,6 +131,13 @@ public class VehicleUnitXmlExtractionService {
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
path
));
vuCardIwIntervals.add(new VuCardIwInterval(
driverKey,
normalizeToken(text(record, "cardSlotNumber")),
from,
to,
path
));
}
if (driversByKey.isEmpty()) {
@ -145,6 +148,9 @@ public class VehicleUnitXmlExtractionService {
));
}
List<ExtractedCardActivityInterval> vuActivityIntervals = extractActivityIntervals(document, vehicleContext, sessionWarnings);
assignActivityCoverage(vuActivityIntervals, vuCardIwIntervals, vehicleContext, driversByKey, sessionWarnings);
List<ExtractionWarning> allWarnings = new ArrayList<>(sessionWarnings);
Map<String, DriverExtractionSession> driverSessions = new LinkedHashMap<>();
int activityCount = 0;
@ -187,7 +193,7 @@ public class VehicleUnitXmlExtractionService {
"Vehicle-unit XML does not contain an Overview block.",
"/VehicleUnit"
));
return new VehicleContext(null, null, null);
return new VehicleContext(null, null, null, null);
}
String registrationNation = text(overview, "vehicleRegistrationIdentification/vehicleRegistrationNation");
@ -217,7 +223,210 @@ public class VehicleUnitXmlExtractionService {
}
OffsetDateTime defaultEnd = offsetDateTime(text(overview, "vuDownloadablePeriod/maxDownloadableTime"));
return new VehicleContext(registration, vehicle, defaultEnd);
OffsetDateTime defaultStart = offsetDateTime(text(overview, "vuDownloadablePeriod/minDownloadableTime"));
return new VehicleContext(registration, vehicle, defaultStart, defaultEnd);
}
private List<ExtractedCardActivityInterval> extractActivityIntervals(
Document document,
VehicleContext vehicleContext,
List<ExtractionWarning> warnings
) {
NodeList dayRecords = nodes(document, "/VehicleUnit/Activities/vuActivityDailyData");
if (dayRecords.getLength() == 0) {
return List.of();
}
LocalDate startDate = vehicleContext.defaultActivityStartDate();
if (startDate == null) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_DATE_INFERENCE_FAILED",
"Vehicle-unit activity daily data is present but no base date could be inferred from the VU downloadable period.",
"/VehicleUnit/Activities/vuActivityDailyData"
));
return List.of();
}
LocalDate maxDate = vehicleContext.defaultActivityEndDate();
if (maxDate != null) {
LocalDate inferredEndDate = startDate.plusDays(dayRecords.getLength() - 1L);
if (inferredEndDate.isAfter(maxDate)) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_DATE_INFERENCE_RANGE",
"Vehicle-unit activity daily records exceed the VU downloadable-period range when mapped by sequence order.",
"/VehicleUnit/Activities/vuActivityDailyData"
));
}
}
List<ExtractedCardActivityInterval> intervals = new ArrayList<>();
int intervalNo = 0;
for (int dayIndex = 0; dayIndex < dayRecords.getLength(); dayIndex++) {
Element dayRecord = (Element) dayRecords.item(dayIndex);
LocalDate date = startDate.plusDays(dayIndex);
String dayPath = "/VehicleUnit/Activities[" + (dayIndex + 1) + "]/vuActivityDailyData";
NodeList changes = nodes(dayRecord, "activityChangeInfos");
List<ActivityChange> parsedChanges = new ArrayList<>();
for (int changeIndex = 0; changeIndex < changes.getLength(); changeIndex++) {
Element change = (Element) changes.item(changeIndex);
OffsetDateTime from = combine(date, text(change, "timeOfChange"));
if (from == null) {
warnings.add(new ExtractionWarning(
"INVALID_VU_ACTIVITY_CHANGE_TIME",
"Vehicle-unit activity change has invalid timeOfChange.",
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
));
continue;
}
parsedChanges.add(new ActivityChange(
from,
normalizeActivity(text(change, "activity")),
normalizeToken(text(change, "slot")),
normalizeToken(text(change, "cardStatus")),
normalizeToken(text(change, "drivingStatus")),
dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
));
}
parsedChanges.sort(Comparator.comparing(ActivityChange::from));
for (int i = 0; i < parsedChanges.size(); i++) {
ActivityChange current = parsedChanges.get(i);
OffsetDateTime to = i + 1 < parsedChanges.size()
? parsedChanges.get(i + 1).from()
: date.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC);
if (!current.from().isBefore(to)) {
continue;
}
intervalNo++;
intervals.add(new ExtractedCardActivityInterval(
"VUACT-" + intervalNo,
current.from(),
to,
current.activityType(),
current.slot(),
current.cardStatus(),
current.drivingStatus(),
null,
null,
current.rawRecordPath()
));
}
}
intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from));
return intervals;
}
private void assignActivityCoverage(
List<ExtractedCardActivityInterval> vuActivityIntervals,
List<VuCardIwInterval> vuCardIwIntervals,
VehicleContext vehicleContext,
Map<String, DriverExtractionBuilder> driversByKey,
List<ExtractionWarning> warnings
) {
for (ExtractedCardActivityInterval interval : vuActivityIntervals) {
List<ActivitySegment> segments = splitByDriverCoverage(interval, vuCardIwIntervals);
if (segments.isEmpty()) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_UNASSIGNED",
"Vehicle-unit activity interval could not be assigned to a driver-card insertion/withdrawal interval.",
interval.rawRecordPath()
));
continue;
}
if (isPartiallyCovered(interval, segments)) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_UNASSIGNED",
"Vehicle-unit activity interval could only be partially assigned to driver-card insertion/withdrawal intervals.",
interval.rawRecordPath()
));
}
for (int i = 0; i < segments.size(); i++) {
ActivitySegment segment = segments.get(i);
DriverExtractionBuilder builder = driversByKey.get(segment.driverKey());
if (builder == null) {
warnings.add(new ExtractionWarning(
"VU_ACTIVITY_DRIVER_MISSING",
"Vehicle-unit activity interval matched a driver key without an initialized driver session.",
interval.rawRecordPath()
));
continue;
}
String intervalId = segments.size() == 1 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
builder.cardActivityIntervals.add(new ExtractedCardActivityInterval(
intervalId,
segment.from(),
segment.to(),
interval.activityType(),
interval.slot(),
interval.cardStatus(),
interval.drivingStatus(),
vehicleContext.registration() == null ? null : vehicleContext.registration().registrationKey(),
vehicleContext.vehicle() == null ? null : vehicleContext.vehicle().vehicleKey(),
interval.rawRecordPath()
));
}
}
}
private boolean isPartiallyCovered(ExtractedCardActivityInterval interval, List<ActivitySegment> segments) {
if (segments.isEmpty()) {
return true;
}
if (!segments.get(0).from().equals(interval.from())) {
return true;
}
if (!segments.get(segments.size() - 1).to().equals(interval.to())) {
return true;
}
for (int i = 1; i < segments.size(); i++) {
if (!segments.get(i - 1).to().equals(segments.get(i).from())) {
return true;
}
}
return false;
}
private List<ActivitySegment> splitByDriverCoverage(
ExtractedCardActivityInterval interval,
List<VuCardIwInterval> vuCardIwIntervals
) {
TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
cutPoints.add(interval.from());
cutPoints.add(interval.to());
List<VuCardIwInterval> matchingIntervals = vuCardIwIntervals.stream()
.filter(iw -> iw.slot() == null || interval.slot() == null || iw.slot().equals(interval.slot()))
.filter(iw -> iw.overlaps(interval.from(), interval.to()))
.toList();
for (VuCardIwInterval iw : matchingIntervals) {
if (iw.from().isAfter(interval.from()) && iw.from().isBefore(interval.to())) {
cutPoints.add(iw.from());
}
OffsetDateTime iwEndExclusive = iw.endExclusive();
if (iwEndExclusive.isAfter(interval.from()) && iwEndExclusive.isBefore(interval.to())) {
cutPoints.add(iwEndExclusive);
}
}
List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
List<ActivitySegment> segments = new ArrayList<>();
for (int i = 0; i < orderedCutPoints.size() - 1; i++) {
OffsetDateTime segmentFrom = orderedCutPoints.get(i);
OffsetDateTime segmentTo = orderedCutPoints.get(i + 1);
if (!segmentFrom.isBefore(segmentTo)) {
continue;
}
VuCardIwInterval covering = matchingIntervals.stream()
.filter(iw -> iw.covers(segmentFrom))
.findFirst()
.orElse(null);
if (covering != null) {
segments.add(new ActivitySegment(covering.driverKey(), segmentFrom, segmentTo));
}
}
return segments;
}
private Element firstElement(Object node, String expression) {
@ -261,6 +470,35 @@ public class VehicleUnitXmlExtractionService {
return Long.parseLong(value.trim());
}
private OffsetDateTime combine(LocalDate date, String timeText) {
if (date == null || timeText == null || timeText.isBlank()) {
return null;
}
LocalTime time = java.time.OffsetTime.parse(timeText.trim()).withOffsetSameInstant(ZoneOffset.UTC).toLocalTime();
return date.atTime(time).atOffset(ZoneOffset.UTC);
}
private String normalizeActivity(String value) {
String normalized = normalizeToken(value);
if (normalized == null) {
return "UNKNOWN_ACTIVITY";
}
return switch (normalized) {
case "DRIVING", "DRIVE" -> "DRIVE";
case "WORK" -> "WORK";
case "AVAILABILITY", "AVAILABLE" -> "AVAILABILITY";
case "BREAK_REST", "BREAK/REST", "REST" -> "BREAK_REST";
default -> "UNKNOWN_ACTIVITY";
};
}
private String normalizeToken(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
}
private String joinCardNumber(Element node, String basePath) {
String driverIdentification = text(node, basePath + "/driverIdentification");
if (driverIdentification == null) {
@ -358,7 +596,52 @@ public class VehicleUnitXmlExtractionService {
private record VehicleContext(
ExtractedVehicleRegistration registration,
ExtractedVehicle vehicle,
OffsetDateTime defaultActivityStart,
OffsetDateTime defaultOpenIntervalEnd
) {
private LocalDate defaultActivityStartDate() {
return defaultActivityStart == null ? null : defaultActivityStart.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
}
private LocalDate defaultActivityEndDate() {
return defaultOpenIntervalEnd == null ? null : defaultOpenIntervalEnd.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
}
}
private record ActivityChange(
OffsetDateTime from,
String activityType,
String slot,
String cardStatus,
String drivingStatus,
String rawRecordPath
) {
}
private record VuCardIwInterval(
String driverKey,
String slot,
OffsetDateTime from,
OffsetDateTime to,
String rawRecordPath
) {
private OffsetDateTime endExclusive() {
return to.plusSeconds(1);
}
private boolean covers(OffsetDateTime timestamp) {
return !from.isAfter(timestamp) && timestamp.isBefore(endExclusive());
}
private boolean overlaps(OffsetDateTime rangeStart, OffsetDateTime rangeEnd) {
return endExclusive().isAfter(rangeStart) && from.isBefore(rangeEnd);
}
}
private record ActivitySegment(
String driverKey,
OffsetDateTime from,
OffsetDateTime to
) {
}
}

View File

@ -52,12 +52,19 @@ class VehicleUnitXmlExtractionServiceTest {
assertThat(firstDriver.cardVehicleUsageIntervals()).hasSize(1);
assertThat(firstDriver.cardVehicleUsageIntervals().get(0).from().toString()).isEqualTo("2026-04-01T08:00Z");
assertThat(firstDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-01T11:00Z");
assertThat(firstDriver.cardActivityIntervals()).hasSize(3);
assertThat(firstDriver.cardActivityIntervals().get(0).activityType()).isEqualTo("WORK");
assertThat(firstDriver.cardActivityIntervals().get(1).activityType()).isEqualTo("DRIVE");
assertThat(firstDriver.cardActivityIntervals().get(2).to().toString()).isEqualTo("2026-04-01T11:00:01Z");
assertThat(secondDriver.cardVehicleUsageIntervals()).hasSize(1);
assertThat(secondDriver.cardVehicleUsageIntervals().get(0).to().toString()).isEqualTo("2026-04-02T10:00Z");
assertThat(secondDriver.cardActivityIntervals()).hasSize(2);
assertThat(secondDriver.cardActivityIntervals().get(0).from().toString()).isEqualTo("2026-04-02T07:30Z");
assertThat(secondDriver.cardActivityIntervals().get(1).to().toString()).isEqualTo("2026-04-02T10:00:01Z");
assertThat(session.warnings()).extracting("code")
.contains("VU_ACTIVITY_NOT_EXTRACTED", "OPEN_VU_CARD_INTERVAL");
.contains("OPEN_VU_CARD_INTERVAL", "VU_ACTIVITY_UNASSIGNED");
}
private Document document(String xml) throws Exception {

View File

@ -15,6 +15,7 @@ final class VehicleUnitXmlSamples {
<vehicleRegistrationNumber><vehicleRegNumber>W-1000V</vehicleRegNumber></vehicleRegistrationNumber>
</vehicleRegistrationIdentification>
<vuDownloadablePeriod>
<minDownloadableTime>2026-04-01T00:00:00Z</minDownloadableTime>
<maxDownloadableTime>2026-04-02T10:00:00Z</maxDownloadableTime>
</vuDownloadablePeriod>
</Overview>
@ -64,7 +65,49 @@ final class VehicleUnitXmlSamples {
<manualInputFlag>MANUAL_ENTRIES</manualInputFlag>
</vuCardIWRecords>
</vuCardIWData>
<vuActivityDailyData/>
<vuActivityDailyData>
<noOfActivityChanges>3</noOfActivityChanges>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>WORK</activity>
<timeOfChange>08:00:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>DRIVING</activity>
<timeOfChange>09:00:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>BREAK/REST</activity>
<timeOfChange>11:00:00Z</timeOfChange>
</activityChangeInfos>
</vuActivityDailyData>
</Activities>
<Activities>
<vuActivityDailyData>
<noOfActivityChanges>2</noOfActivityChanges>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>WORK</activity>
<timeOfChange>07:30:00Z</timeOfChange>
</activityChangeInfos>
<activityChangeInfos>
<slot>DRIVER</slot>
<drivingStatus>SINGLE</drivingStatus>
<cardStatus>INSERTED</cardStatus>
<activity>DRIVING</activity>
<timeOfChange>08:00:00Z</timeOfChange>
</activityChangeInfos>
</vuActivityDailyData>
</Activities>
</VehicleUnit>
""";