diff --git a/README_PATCH.md b/README_PATCH.md
index 05dde7a..ef630bc 100644
--- a/README_PATCH.md
+++ b/README_PATCH.md
@@ -1,40 +1,32 @@
-# Cross-representation tachograph event mixing fix
+# Generic runtime event mixing refactoring
-## Problem
+This patch removes tachograph-specific source roles and representations from the common runtime mixing contracts.
-A runtime request that loads both `TACHOGRAPH_FILE_SESSION` and `TACHOGRAPH_DB` can contain the same tachograph observation twice. The earlier mixing implementation primarily handled CARD versus VU evidence. It did not reliably classify the semantic source role and physical representation of every event, and it did not suppress duplicate observations when both copies had the same source role (for example VU file-session plus VU database serialization).
+## Main changes
-This produced nearly doubled activity and support-event results in mixed executions.
+- `RuntimeEventMixingRule` now uses generic `RuntimeEventSelector` objects, generic pair constraints, compatibility-policy ids, and fusion-policy ids.
+- `RuntimeEventDescriptor` and `RuntimeEventSourceProfile` expose opaque classification values instead of `RuntimeTachographEvidenceSourceRole` and `RuntimeTachographRepresentation` fields.
+- `RuntimeEventDescriptorFactory` consumes pluggable `RuntimeEventSemantics` adapters.
+- `RuntimeEventMixingRuleRegistry` aggregates pluggable `RuntimeEventMixingRuleProvider` implementations.
+- Compatibility and primary-event enrichment are delegated to policy registries.
+- Tachograph-specific country/region/coordinate normalization and vehicle/VIN enrichment were moved out of common classes.
+- Common diagnostics now expose classification-count maps instead of tachograph-specific scalar fields.
+- `eventMixingMode=FULL` is the generic default. The tachograph provider still accepts `TACHOGRAPH_SAME_SOURCE` for request compatibility.
-## Implementation
+## Tachograph behavior retained
-- Added semantic tachograph evidence source roles:
- - `DRIVER_CARD`
- - `VEHICLE_UNIT`
- - `UNKNOWN`
-- Added physical representation classification:
- - `FILE_SESSION`
- - `DATABASE`
- - `UNKNOWN`
-- Source-role inference now uses extraction code, raw source metadata, package event-source metadata, package kind and external event identifiers.
-- Database runtime packages identified by `RUNTIME:TACHOGRAPH:*` take precedence over retained original file-session package metadata.
-- Existing CARD/VU rules now use semantic source roles, with extraction codes retained only as fallback for unclassified events.
-- Added same-source-role cross-representation rules for activity and support evidence:
- - database representation is retained as primary;
- - matching file-session representation is suppressed as a duplicate;
- - exact timestamps remain required;
- - semantic compatibility checks still protect meaningful conflicts.
-- `CARD_VEHICLES_USED` and `IW_CYCLE` remain untouched by event mixing and continue to be handled by interval-level reconciliation.
-- Added mixing diagnostics for source roles, representations, candidate groups, rejected compatibility pairs and suppressed events.
-- Fixed tachograph place SQL so entry type `6` is classified as `START`, consistently with file-session parsing.
+The tachograph provider still implements:
-## Tests added/updated
-
-- Same VU place observation from file session and database is reduced to one event.
-- Same CARD activity observation from file session and database is reduced to one event.
-- Database/file-session representation classification remains correct even when DB rows retain original file-session package metadata.
-- Existing CARD/VU parity and CVU/IW preservation tests remain covered.
+- database/file-session same-role duplicate suppression;
+- driver-card/vehicle-unit activity mixing;
+- driver-card/vehicle-unit support-event mixing;
+- semantic place lifecycle normalization;
+- tachograph nation and region normalization;
+- coordinate tolerance checks;
+- vehicle/VIN enrichment of the retained primary event.
## Validation
-The modified Java files were passed through a `javac` parser check. Only expected missing dependency/classpath errors were reported; no Java syntax errors were detected. Maven and a Maven wrapper are unavailable in the execution environment, so the full project test suite was not executed.
+- The complete modified mixing package was compiled with Java 21 using local stubs for external Spring/Jackson/validation APIs.
+- A Java runtime harness confirmed that duplicate DB/file-session driver-card activity events are reduced to one event and that generic classification diagnostics are populated.
+- Maven is not installed in the execution environment, so the complete project test suite was not run.
diff --git a/docs/runtime-event-mixing.md b/docs/runtime-event-mixing.md
new file mode 100644
index 0000000..e4c2a44
--- /dev/null
+++ b/docs/runtime-event-mixing.md
@@ -0,0 +1,62 @@
+# Runtime event mixing architecture
+
+The runtime mixing engine is source-neutral. Source-specific behavior is contributed through four extension points:
+
+1. `RuntimeEventSemantics` classifies an event and supplies semantic lifecycle normalization.
+2. `RuntimeEventMixingRuleProvider` supplies source/domain-specific rules.
+3. `RuntimeEventCompatibilityPolicy` validates candidate pairs after broad grouping.
+4. `RuntimeEventFusionPolicy` enriches or transforms the retained primary event.
+
+## Generic classifications
+
+`RuntimeEventSourceProfile` stores opaque classifications rather than domain-specific enums. The built-in keys are:
+
+- `sourceFamily`
+- `sourceRole`
+- `representation`
+- `extractionCode`
+
+The common engine does not interpret their values. A tachograph adapter currently publishes values such as `TACHOGRAPH`, `DRIVER_CARD`, `VEHICLE_UNIT`, `DATABASE`, and `FILE_SESSION`. Another source family can publish different values without changing `RuntimeEventMixingRule` or `RuntimeEventMixingService`.
+
+## Generic rule structure
+
+A rule consists of:
+
+- channel and event-domain/type/lifecycle filters;
+- an equivalence-key type;
+- a primary `RuntimeEventSelector`;
+- a secondary `RuntimeEventSelector`;
+- optional `RuntimeEventPairConstraint` values, such as equal `sourceRole`;
+- a compatibility-policy id;
+- a fusion-policy id;
+- output roles and audit text.
+
+Source-specific values belong only in a source-specific `RuntimeEventMixingRuleProvider`.
+
+## Tachograph plugin
+
+The tachograph implementation is isolated in:
+
+- `RuntimeTachographEventSemantics`
+- `RuntimeTachographEventMixingRuleProvider`
+- `RuntimeTachographEvidenceCompatibilityPolicy`
+- `RuntimeTachographActivityCompatibilityPolicy`
+- `RuntimeTachographVehicleIdentityFusionPolicy`
+
+The generic classes do not import tachograph source-role or representation types.
+
+## Diagnostics
+
+Diagnostics are now emitted as generic maps:
+
+- `sourceFamilyCounts`
+- `sourceRoleCounts`
+- `representationCounts`
+
+This replaces tachograph-specific scalar counters in the event-mixing module metadata.
+
+## Modes
+
+- `OFF` disables all providers.
+- `FULL` enables all providers that support the mode.
+- The tachograph provider continues to accept the legacy `TACHOGRAPH_SAME_SOURCE` value.
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventClassificationKeys.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventClassificationKeys.java
new file mode 100644
index 0000000..058257d
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventClassificationKeys.java
@@ -0,0 +1,19 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+/**
+ * Well-known generic classification dimensions understood by the runtime mixing engine.
+ *
+ *
Values are supplied by source-specific semantic adapters. The mixing engine treats both
+ * keys and values as opaque strings and therefore does not depend on tachograph or any other
+ * source domain.
+ */
+public final class RuntimeEventClassificationKeys {
+
+ public static final String SOURCE_FAMILY = "sourceFamily";
+ public static final String SOURCE_ROLE = "sourceRole";
+ public static final String REPRESENTATION = "representation";
+ public static final String EXTRACTION_CODE = "extractionCode";
+
+ private RuntimeEventClassificationKeys() {
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventCompatibilityPolicy.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventCompatibilityPolicy.java
new file mode 100644
index 0000000..03579b8
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventCompatibilityPolicy.java
@@ -0,0 +1,9 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+/** Source/domain-specific compatibility check selected by a generic mixing rule. */
+public interface RuntimeEventCompatibilityPolicy {
+
+ String policyId();
+
+ boolean compatible(RuntimeEventDescriptor primary, RuntimeEventDescriptor secondary);
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventCompatibilityPolicyRegistry.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventCompatibilityPolicyRegistry.java
new file mode 100644
index 0000000..36539e4
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventCompatibilityPolicyRegistry.java
@@ -0,0 +1,57 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class RuntimeEventCompatibilityPolicyRegistry {
+
+ private final Map policies;
+
+ @Autowired
+ public RuntimeEventCompatibilityPolicyRegistry(List policies) {
+ this.policies = policiesById(policies);
+ }
+
+
+ public boolean compatible(
+ RuntimeEventMixingRule rule,
+ RuntimeEventDescriptor primary,
+ RuntimeEventDescriptor secondary
+ ) {
+ if (rule == null || primary == null || secondary == null) {
+ return false;
+ }
+ if (!rule.pairConstraint().matches(primary, secondary)) {
+ return false;
+ }
+ String policyId = normalize(rule.compatibilityPolicyId());
+ if (RuntimeEventMixingRule.COMPATIBILITY_ALWAYS.equals(policyId)) {
+ return true;
+ }
+ RuntimeEventCompatibilityPolicy policy = policies.get(policyId);
+ return policy != null && policy.compatible(primary, secondary);
+ }
+
+ private static Map policiesById(
+ List policies
+ ) {
+ Map byId = new LinkedHashMap<>();
+ if (policies != null) {
+ for (RuntimeEventCompatibilityPolicy policy : policies) {
+ if (policy != null && policy.policyId() != null && !policy.policyId().isBlank()) {
+ byId.put(normalize(policy.policyId()), policy);
+ }
+ }
+ }
+ return Map.copyOf(byId);
+ }
+
+ private static String normalize(String value) {
+ return value == null ? null : value.trim().toUpperCase(Locale.ROOT);
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptor.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptor.java
index d44229d..d46e07b 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptor.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptor.java
@@ -11,12 +11,10 @@ public record RuntimeEventDescriptor(
String eventIdentityKey,
String eventKey,
RuntimeEventSourceProfile sourceProfile,
- RuntimeTachographEvidenceSourceRole evidenceSourceRole,
- RuntimeTachographRepresentation representation,
String compatibleActivityKey,
String compatibleSupportEvidenceKey,
boolean driverActivityPoint,
- boolean driverCardUsagePoint,
+ boolean vehicleUsageInputCandidate,
boolean supportEvidenceCandidate
) {
public EventDomain eventDomain() {
@@ -39,16 +37,8 @@ public record RuntimeEventDescriptor(
return sourceProfile == null ? null : sourceProfile.extractionCode();
}
- public RuntimeTachographEvidenceSourceRole evidenceSourceRole() {
- return evidenceSourceRole == null
- ? RuntimeTachographEvidenceSourceRole.UNKNOWN
- : evidenceSourceRole;
- }
-
- public RuntimeTachographRepresentation representation() {
- return representation == null
- ? RuntimeTachographRepresentation.UNKNOWN
- : representation;
+ public String classification(String key) {
+ return sourceProfile == null ? null : sourceProfile.classification(key);
}
public String keyFor(String equivalenceType) {
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptorFactory.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptorFactory.java
index 20fa484..870e84d 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptorFactory.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventDescriptorFactory.java
@@ -15,17 +15,13 @@ import org.springframework.stereotype.Component;
@Component
public class RuntimeEventDescriptorFactory {
- private final RuntimeTachographEventSemantics tachographSemantics;
+ private final List semanticsAdapters;
@Autowired
- public RuntimeEventDescriptorFactory(RuntimeTachographEventSemantics tachographSemantics) {
- this.tachographSemantics = tachographSemantics;
+ public RuntimeEventDescriptorFactory(List semanticsAdapters) {
+ this.semanticsAdapters = semanticsAdapters == null ? List.of() : List.copyOf(semanticsAdapters);
}
- /** Compatibility constructor used by unit tests. */
- public RuntimeEventDescriptorFactory() {
- this(new RuntimeTachographEventSemantics());
- }
public List describeSorted(List events) {
return sort(events).stream()
@@ -34,18 +30,19 @@ public class RuntimeEventDescriptorFactory {
}
public RuntimeEventDescriptor describe(EventHubEventDto event) {
- RuntimeEventSourceProfile profile = sourceProfile(event);
+ RuntimeEventSemantics semantics = semanticsFor(event);
+ RuntimeEventSourceProfile profile = semantics == null
+ ? defaultSourceProfile(event)
+ : semantics.sourceProfile(event);
return new RuntimeEventDescriptor(
event,
eventIdentityKey(event),
RuntimeEventIdentityResolver.canonicalEventKey(event),
profile,
- profile.evidenceSourceRole(),
- profile.representation(),
compatibleActivityKey(event),
- compatibleSupportEvidenceKey(event),
+ compatibleSupportEvidenceKey(event, semantics),
isDriverActivityPoint(event),
- isDriverCardUsagePoint(event),
+ isVehicleUsageInputCandidate(event),
isSupportEvidenceCandidate(event)
);
}
@@ -64,19 +61,25 @@ public class RuntimeEventDescriptorFactory {
&& event.occurredAt() != null;
}
- public boolean isDriverCardUsagePoint(EventHubEventDto event) {
+ public boolean isVehicleUsageInputCandidate(EventHubEventDto event) {
return event != null
&& event.eventDomain() == EventDomain.DRIVER_CARD
&& (event.lifecycle() == EventLifecycle.INSERT || event.lifecycle() == EventLifecycle.WITHDRAW)
&& event.occurredAt() != null;
}
+ /** Compatibility alias for existing callers. */
+ public boolean isDriverCardUsagePoint(EventHubEventDto event) {
+ return isVehicleUsageInputCandidate(event);
+ }
+
public boolean isSupportEvidenceCandidate(EventHubEventDto event) {
- return event != null && !isDriverActivityPoint(event) && !isDriverCardUsagePoint(event);
+ return event != null && !isDriverActivityPoint(event) && !isVehicleUsageInputCandidate(event);
}
public RuntimeEventSourceProfile sourceProfile(EventHubEventDto event) {
- return tachographSemantics.sourceProfile(event);
+ RuntimeEventSemantics semantics = semanticsFor(event);
+ return semantics == null ? defaultSourceProfile(event) : semantics.sourceProfile(event);
}
public String eventIdentityKey(EventHubEventDto event) {
@@ -99,6 +102,27 @@ public class RuntimeEventDescriptorFactory {
.thenComparing(EventHubEventDto::externalSourceEventId, Comparator.nullsLast(String::compareTo));
}
+ private RuntimeEventSemantics semanticsFor(EventHubEventDto event) {
+ for (RuntimeEventSemantics semantics : semanticsAdapters) {
+ if (semantics != null && semantics.supports(event)) {
+ return semantics;
+ }
+ }
+ return null;
+ }
+
+ private RuntimeEventSourceProfile defaultSourceProfile(EventHubEventDto event) {
+ String sourceSystem = event == null || event.packageInfo() == null
+ || event.packageInfo().eventSource() == null
+ ? null
+ : event.packageInfo().eventSource().providerKey();
+ String sourceKind = event == null || event.packageInfo() == null
+ || event.packageInfo().eventSource() == null
+ ? null
+ : event.packageInfo().eventSource().sourceKind();
+ return new RuntimeEventSourceProfile(sourceSystem, sourceKind, null);
+ }
+
private String compatibleActivityKey(EventHubEventDto event) {
return String.join("|",
"ACTIVITY_COMPATIBLE",
@@ -111,22 +135,20 @@ public class RuntimeEventDescriptorFactory {
);
}
- private String compatibleSupportEvidenceKey(EventHubEventDto event) {
+ private String compatibleSupportEvidenceKey(EventHubEventDto event, RuntimeEventSemantics semantics) {
return String.join("|",
"SUPPORT_COMPATIBLE",
nullToEmpty(RuntimeEntityReferenceResolver.driverKey(event)),
nullToEmpty(event == null || event.eventDomain() == null ? null : event.eventDomain().name()),
nullToEmpty(event == null || event.eventType() == null ? null : event.eventType().name()),
- nullToEmpty(semanticSupportLifecycle(event)),
+ nullToEmpty(semantics == null
+ ? event == null || event.lifecycle() == null ? null : event.lifecycle().name()
+ : semantics.semanticLifecycle(event)),
normalizeTime(event == null ? null : event.occurredAt()),
nullToEmpty(RuntimeEntityReferenceResolver.registrationKey(event))
);
}
- private String semanticSupportLifecycle(EventHubEventDto event) {
- return tachographSemantics.semanticLifecycle(event);
- }
-
private String firstNonBlank(String... values) {
if (values == null) {
return null;
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventEvidenceCompatibilityMatcher.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventEvidenceCompatibilityMatcher.java
index f63056c..27a0845 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventEvidenceCompatibilityMatcher.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventEvidenceCompatibilityMatcher.java
@@ -1,267 +1,27 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
-import at.procon.eventhub.dto.EventHubEventDto;
-import at.procon.eventhub.dto.GeoPointDto;
-import at.procon.eventhub.dto.VehicleRefDto;
-import at.procon.eventhub.dto.VehicleRegistrationRefDto;
-import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
-import at.procon.eventhub.reference.TachographNationRegistry;
-import com.fasterxml.jackson.databind.JsonNode;
-import java.math.BigDecimal;
-import java.util.Locale;
-import java.util.Objects;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
-/**
- * Performs semantic compatibility checks after broad event candidates have been grouped.
- *
- * File-session and database representations of the same tachograph fact can differ in
- * serialization details such as decimal scale, nation representation, default region values,
- * optional vehicle identity and interval metadata. Those representation details must not be part
- * of the candidate key, but meaningful conflicts must still prevent fusion.
- */
+/** Generic compatibility dispatcher retained under the existing service name. */
@Component
public class RuntimeEventEvidenceCompatibilityMatcher {
- private static final BigDecimal GEO_TOLERANCE = new BigDecimal("0.000000001");
+ private final RuntimeEventCompatibilityPolicyRegistry policyRegistry;
+
+ @Autowired
+ public RuntimeEventEvidenceCompatibilityMatcher(
+ RuntimeEventCompatibilityPolicyRegistry policyRegistry
+ ) {
+ this.policyRegistry = policyRegistry;
+ }
+
public boolean compatible(
RuntimeEventMixingRule rule,
RuntimeEventDescriptor primary,
RuntimeEventDescriptor secondary
) {
- if (rule == null || primary == null || secondary == null) {
- return false;
- }
- if (rule.requireSameSourceRole()
- && primary.evidenceSourceRole() != secondary.evidenceSourceRole()) {
- return false;
- }
- return switch (rule.equivalenceType()) {
- case RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY -> true;
- case RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY ->
- activityCompatible(primary.event(), secondary.event());
- case RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY ->
- supportCompatible(primary.event(), secondary.event());
- default -> false;
- };
- }
-
- private boolean activityCompatible(EventHubEventDto left, EventHubEventDto right) {
- return tenantCompatible(left, right)
- && registrationCompatible(left, right)
- && vehicleIdentityCompatible(left, right)
- && optionalTokenCompatible(activitySlot(left), activitySlot(right));
- }
-
- private boolean supportCompatible(EventHubEventDto left, EventHubEventDto right) {
- return tenantCompatible(left, right)
- && registrationCompatible(left, right)
- && vehicleIdentityCompatible(left, right)
- && coordinatesCompatible(left, right)
- && odometerCompatible(left, right)
- && nationCompatible(detailValue(left, "country"), detailValue(right, "country"))
- && regionCompatible(detailValue(left, "region"), detailValue(right, "region"))
- && nationCompatible(detailValue(left, "countryFrom"), detailValue(right, "countryFrom"))
- && nationCompatible(detailValue(left, "countryTo"), detailValue(right, "countryTo"))
- && optionalTokenCompatible(detailValue(left, "operation"), detailValue(right, "operation"));
- }
-
- private boolean tenantCompatible(EventHubEventDto left, EventHubEventDto right) {
- return optionalTokenCompatible(normalizedTenant(left), normalizedTenant(right));
- }
-
- private String normalizedTenant(EventHubEventDto event) {
- String value = event == null || event.packageInfo() == null ? null : event.packageInfo().tenantKey();
- String normalized = normalizeToken(value);
- return Objects.equals("DEFAULT", normalized) ? null : normalized;
- }
-
- private boolean registrationCompatible(EventHubEventDto left, EventHubEventDto right) {
- return optionalTokenCompatible(normalizedRegistration(left), normalizedRegistration(right));
- }
-
- private String normalizedRegistration(EventHubEventDto event) {
- VehicleRefDto vehicleRef = event == null ? null : event.vehicleRef();
- VehicleRegistrationRefDto registration = vehicleRef == null ? null : vehicleRef.vehicleRegistration();
- if (registration != null && registration.hasValue()) {
- String nation = normalizedNation(registration.nation(), registration.nationNumericCode());
- String number = normalizeIdentifier(registration.number());
- return nullToEmpty(nation) + ":" + nullToEmpty(number);
- }
-
- String key = RuntimeEntityReferenceResolver.registrationKey(event);
- if (key == null || key.isBlank()) {
- return null;
- }
- int separator = key.indexOf(':');
- if (separator < 0) {
- return normalizeIdentifier(key);
- }
- String nation = normalizedNation(key.substring(0, separator), null);
- String number = normalizeIdentifier(key.substring(separator + 1));
- return nullToEmpty(nation) + ":" + nullToEmpty(number);
- }
-
- private boolean vehicleIdentityCompatible(EventHubEventDto left, EventHubEventDto right) {
- String leftVin = normalizedVin(left);
- String rightVin = normalizedVin(right);
- return optionalTokenCompatible(leftVin, rightVin);
- }
-
- private String normalizedVin(EventHubEventDto event) {
- String vehicleKey = RuntimeEntityReferenceResolver.vehicleKey(event);
- if (vehicleKey != null) {
- return normalizeIdentifier(vehicleKey);
- }
- VehicleRefDto vehicleRef = event == null ? null : event.vehicleRef();
- return vehicleRef == null ? null : normalizeIdentifier(vehicleRef.vin());
- }
-
- private boolean coordinatesCompatible(EventHubEventDto left, EventHubEventDto right) {
- BigDecimal leftLatitude = coordinate(left, true);
- BigDecimal rightLatitude = coordinate(right, true);
- BigDecimal leftLongitude = coordinate(left, false);
- BigDecimal rightLongitude = coordinate(right, false);
- return optionalDecimalCompatible(leftLatitude, rightLatitude, GEO_TOLERANCE)
- && optionalDecimalCompatible(leftLongitude, rightLongitude, GEO_TOLERANCE);
- }
-
- private BigDecimal coordinate(EventHubEventDto event, boolean latitude) {
- GeoPointDto position = event == null ? null : event.position();
- BigDecimal value = position == null ? null : latitude ? position.latitude() : position.longitude();
- if (value != null) {
- return value;
- }
- return decimal(rawValue(event, latitude ? "latitude" : "longitude"));
- }
-
- private boolean odometerCompatible(EventHubEventDto left, EventHubEventDto right) {
- BigDecimal leftValue = odometerM(left);
- BigDecimal rightValue = odometerM(right);
- return optionalDecimalCompatible(leftValue, rightValue, BigDecimal.ZERO);
- }
-
- private BigDecimal odometerM(EventHubEventDto event) {
- if (event != null && event.odometerM() != null) {
- return BigDecimal.valueOf(event.odometerM());
- }
- BigDecimal meters = decimal(rawValue(event, "odometerM"));
- if (meters != null) {
- return meters;
- }
- BigDecimal kilometres = decimal(rawValue(event, "odometerKm"));
- return kilometres == null ? null : kilometres.multiply(BigDecimal.valueOf(1000));
- }
-
- private String activitySlot(EventHubEventDto event) {
- return firstNonBlank(
- rawValue(event, "slot"),
- rawValue(event, "cardSlot"),
- detailAttribute(event, "cardSlot")
- );
- }
-
- private String detailValue(EventHubEventDto event, String field) {
- return firstNonBlank(rawValue(event, field), detailAttribute(event, field));
- }
-
- private String rawValue(EventHubEventDto event, String field) {
- return text(RuntimeEntityReferenceResolver.rawPayload(event), field);
- }
-
- private String detailAttribute(EventHubEventDto event, String field) {
- JsonNode attributes = event == null || event.eventDetails() == null
- ? null
- : event.eventDetails().attributes();
- return text(attributes, field);
- }
-
- private boolean nationCompatible(String left, String right) {
- String normalizedLeft = normalizedNation(left, null);
- String normalizedRight = normalizedNation(right, null);
- return optionalTokenCompatible(normalizedLeft, normalizedRight);
- }
-
- private String normalizedNation(String nation, Integer numericCode) {
- TachographNationRegistry.NationResolution resolution =
- TachographNationRegistry.resolve(nation, numericCode);
- if (resolution.numericCode() != null) {
- return String.valueOf(resolution.numericCode());
- }
- return normalizeToken(resolution.legacyNation());
- }
-
- private boolean regionCompatible(String left, String right) {
- return optionalTokenCompatible(normalizedRegion(left), normalizedRegion(right));
- }
-
- private String normalizedRegion(String value) {
- String normalized = normalizeToken(value);
- return normalized == null || Objects.equals("0", normalized) ? null : normalized;
- }
-
- private boolean optionalTokenCompatible(String left, String right) {
- String normalizedLeft = normalizeToken(left);
- String normalizedRight = normalizeToken(right);
- return normalizedLeft == null || normalizedRight == null || normalizedLeft.equals(normalizedRight);
- }
-
- private boolean optionalDecimalCompatible(BigDecimal left, BigDecimal right, BigDecimal tolerance) {
- if (left == null || right == null) {
- return true;
- }
- return left.subtract(right).abs().compareTo(tolerance) <= 0;
- }
-
- private BigDecimal decimal(String value) {
- if (value == null || value.isBlank()) {
- return null;
- }
- try {
- return new BigDecimal(value.trim());
- } catch (NumberFormatException ignored) {
- return null;
- }
- }
-
- private String text(JsonNode node, String field) {
- if (node == null || field == null) {
- return null;
- }
- JsonNode value = node.get(field);
- if (value == null || value.isNull()) {
- return null;
- }
- String text = value.asText(null);
- return text == null || text.isBlank() ? null : text.trim();
- }
-
- private String normalizeIdentifier(String value) {
- if (value == null || value.isBlank()) {
- return null;
- }
- String normalized = value.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
- return normalized.isBlank() ? null : normalized;
- }
-
- private String normalizeToken(String value) {
- return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT);
- }
-
- private String firstNonBlank(String... values) {
- if (values == null) {
- return null;
- }
- for (String value : values) {
- if (value != null && !value.isBlank()) {
- return value.trim();
- }
- }
- return null;
- }
-
- private String nullToEmpty(String value) {
- return value == null ? "" : value;
+ return policyRegistry.compatible(rule, primary, secondary);
}
}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventFusionPolicy.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventFusionPolicy.java
new file mode 100644
index 0000000..5c3bb6d
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventFusionPolicy.java
@@ -0,0 +1,12 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import at.procon.eventhub.dto.EventHubEventDto;
+import java.util.List;
+
+/** Source/domain-specific primary-event enrichment selected by a generic mixing rule. */
+public interface RuntimeEventFusionPolicy {
+
+ String policyId();
+
+ EventHubEventDto fuse(EventHubEventDto primary, List secondaries);
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventFusionPolicyRegistry.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventFusionPolicyRegistry.java
new file mode 100644
index 0000000..74b2ab5
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventFusionPolicyRegistry.java
@@ -0,0 +1,49 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import at.procon.eventhub.dto.EventHubEventDto;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+@Component
+public class RuntimeEventFusionPolicyRegistry {
+
+ private final Map policies;
+
+ @Autowired
+ public RuntimeEventFusionPolicyRegistry(List policies) {
+ Map byId = new LinkedHashMap<>();
+ if (policies != null) {
+ for (RuntimeEventFusionPolicy policy : policies) {
+ if (policy != null && policy.policyId() != null && !policy.policyId().isBlank()) {
+ byId.put(normalize(policy.policyId()), policy);
+ }
+ }
+ }
+ this.policies = Map.copyOf(byId);
+ }
+
+
+ public EventHubEventDto fuse(
+ RuntimeEventMixingRule rule,
+ EventHubEventDto primary,
+ List secondaries
+ ) {
+ if (rule == null || primary == null) {
+ return primary;
+ }
+ String policyId = normalize(rule.fusionPolicyId());
+ if (RuntimeEventMixingRule.FUSION_KEEP_PRIMARY.equals(policyId)) {
+ return primary;
+ }
+ RuntimeEventFusionPolicy policy = policies.get(policyId);
+ return policy == null ? primary : policy.fuse(primary, secondaries);
+ }
+
+ private static String normalize(String value) {
+ return value == null ? null : value.trim().toUpperCase(Locale.ROOT);
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingDiagnostics.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingDiagnostics.java
index 21a309b..a5b005d 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingDiagnostics.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingDiagnostics.java
@@ -1,17 +1,24 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
-/** Classification and rule-application counters for one event-mixing execution. */
+import java.util.Map;
+
+/** Generic classification and rule-application counters for one event-mixing execution. */
public record RuntimeEventMixingDiagnostics(
int describedEventCount,
- int tachographEventCount,
- int driverCardSourceRoleCount,
- int vehicleUnitSourceRoleCount,
- int unknownSourceRoleCount,
- int databaseRepresentationCount,
- int fileSessionRepresentationCount,
- int unknownRepresentationCount,
+ Map sourceFamilyCounts,
+ Map sourceRoleCounts,
+ Map representationCounts,
int candidateGroupCount,
int compatibilityRejectedCount,
int suppressedEventCount
) {
+ public RuntimeEventMixingDiagnostics {
+ sourceFamilyCounts = sourceFamilyCounts == null ? Map.of() : Map.copyOf(sourceFamilyCounts);
+ sourceRoleCounts = sourceRoleCounts == null ? Map.of() : Map.copyOf(sourceRoleCounts);
+ representationCounts = representationCounts == null ? Map.of() : Map.copyOf(representationCounts);
+ }
+
+ public int count(Map counts, String value) {
+ return value == null || counts == null ? 0 : counts.getOrDefault(value, 0);
+ }
}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRule.java
index 4e6323c..5a939da 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRule.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRule.java
@@ -5,20 +5,25 @@ import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import java.util.Set;
+/**
+ * Generic event-mixing rule.
+ *
+ * Source-specific concepts are expressed through opaque selector classifications and policy
+ * identifiers. This record intentionally has no dependency on tachograph-specific roles,
+ * representations or normalization rules.
+ */
public record RuntimeEventMixingRule(
String ruleId,
RuntimeEventMixingChannel channel,
String equivalenceType,
+ String compatibilityPolicyId,
+ String fusionPolicyId,
Set eventDomains,
Set eventTypes,
Set lifecycles,
- Set primaryExtractionCodes,
- Set secondaryExtractionCodes,
- Set primarySourceRoles,
- Set secondarySourceRoles,
- Set primaryRepresentations,
- Set secondaryRepresentations,
- boolean requireSameSourceRole,
+ RuntimeEventSelector primarySelector,
+ RuntimeEventSelector secondarySelector,
+ RuntimeEventPairConstraint pairConstraint,
RuntimeResolvedEventRole primaryRole,
RuntimeResolvedEventRole secondaryRole,
String decision,
@@ -28,25 +33,24 @@ public record RuntimeEventMixingRule(
public static final String EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY = "COMPATIBLE_ACTIVITY_KEY";
public static final String EQUIVALENCE_COMPATIBLE_SUPPORT_KEY = "COMPATIBLE_SUPPORT_KEY";
+ public static final String COMPATIBILITY_ALWAYS = "ALWAYS";
+ public static final String FUSION_KEEP_PRIMARY = "KEEP_PRIMARY";
+
public RuntimeEventMixingRule {
eventDomains = eventDomains == null ? Set.of() : Set.copyOf(eventDomains);
eventTypes = eventTypes == null ? Set.of() : Set.copyOf(eventTypes);
lifecycles = lifecycles == null ? Set.of() : Set.copyOf(lifecycles);
- primaryExtractionCodes = normalize(primaryExtractionCodes);
- secondaryExtractionCodes = normalize(secondaryExtractionCodes);
- primarySourceRoles = primarySourceRoles == null ? Set.of() : Set.copyOf(primarySourceRoles);
- secondarySourceRoles = secondarySourceRoles == null ? Set.of() : Set.copyOf(secondarySourceRoles);
- primaryRepresentations = primaryRepresentations == null ? Set.of() : Set.copyOf(primaryRepresentations);
- secondaryRepresentations = secondaryRepresentations == null ? Set.of() : Set.copyOf(secondaryRepresentations);
+ primarySelector = primarySelector == null ? RuntimeEventSelector.ANY : primarySelector;
+ secondarySelector = secondarySelector == null ? RuntimeEventSelector.ANY : secondarySelector;
+ pairConstraint = pairConstraint == null ? RuntimeEventPairConstraint.NONE : pairConstraint;
+ compatibilityPolicyId = normalizePolicy(compatibilityPolicyId, COMPATIBILITY_ALWAYS);
+ fusionPolicyId = normalizePolicy(fusionPolicyId, FUSION_KEEP_PRIMARY);
}
public boolean matches(RuntimeEventDescriptor descriptor) {
if (descriptor == null || descriptor.event() == null || descriptor.sourceProfile() == null) {
return false;
}
- if (!descriptor.sourceProfile().isTachographRuntimeSource()) {
- return false;
- }
if (!eventDomains.isEmpty() && !eventDomains.contains(descriptor.eventDomain())) {
return false;
}
@@ -59,6 +63,9 @@ public record RuntimeEventMixingRule(
if (channel == RuntimeEventMixingChannel.ACTIVITY_TIMELINE && !descriptor.driverActivityPoint()) {
return false;
}
+ if (channel == RuntimeEventMixingChannel.VEHICLE_USAGE && !descriptor.vehicleUsageInputCandidate()) {
+ return false;
+ }
if (channel == RuntimeEventMixingChannel.SUPPORT_EVIDENCE && !descriptor.supportEvidenceCandidate()) {
return false;
}
@@ -66,66 +73,16 @@ public record RuntimeEventMixingRule(
}
public boolean isPrimary(RuntimeEventDescriptor descriptor) {
- return sideMatches(
- descriptor,
- primaryExtractionCodes,
- primarySourceRoles,
- primaryRepresentations
- );
+ return primarySelector.matches(descriptor);
}
public boolean isSecondary(RuntimeEventDescriptor descriptor) {
- return sideMatches(
- descriptor,
- secondaryExtractionCodes,
- secondarySourceRoles,
- secondaryRepresentations
- );
+ return secondarySelector.matches(descriptor);
}
- private boolean sideMatches(
- RuntimeEventDescriptor descriptor,
- Set extractionCodes,
- Set sourceRoles,
- Set representations
- ) {
- if (descriptor == null) {
- return false;
- }
- if (!representations.isEmpty() && !representations.contains(descriptor.representation())) {
- return false;
- }
-
- if (!sourceRoles.isEmpty()) {
- RuntimeTachographEvidenceSourceRole role = descriptor.evidenceSourceRole();
- if (role != RuntimeTachographEvidenceSourceRole.UNKNOWN) {
- return sourceRoles.contains(role);
- }
- // Extraction code is a fallback only when the semantic source role could not be
- // resolved. A conflicting explicit role must never make one event both primary and
- // secondary.
- return !extractionCodes.isEmpty()
- && extractionCodes.contains(normalize(descriptor.extractionCode()));
- }
- if (!extractionCodes.isEmpty()) {
- return extractionCodes.contains(normalize(descriptor.extractionCode()));
- }
- return true;
- }
-
- private static Set normalize(Set values) {
- if (values == null || values.isEmpty()) {
- return Set.of();
- }
- return values.stream()
- .filter(value -> value != null && !value.isBlank())
- .map(RuntimeEventMixingRule::normalize)
- .collect(java.util.stream.Collectors.toUnmodifiableSet());
- }
-
- private static String normalize(String value) {
+ private static String normalizePolicy(String value, String fallback) {
return value == null || value.isBlank()
- ? null
+ ? fallback
: value.trim().toUpperCase(java.util.Locale.ROOT);
}
}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRuleProvider.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRuleProvider.java
new file mode 100644
index 0000000..7b595b2
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRuleProvider.java
@@ -0,0 +1,9 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import java.util.List;
+
+/** Supplies source/domain-specific rules to the generic runtime mixing registry. */
+public interface RuntimeEventMixingRuleProvider {
+
+ List rulesForMode(String mode);
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRuleRegistry.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRuleRegistry.java
index de26274..3085716 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRuleRegistry.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingRuleRegistry.java
@@ -1,218 +1,27 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
-import at.procon.eventhub.dto.EventDomain;
-import at.procon.eventhub.dto.EventLifecycle;
-import at.procon.eventhub.dto.EventType;
import java.util.List;
-import java.util.Set;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
+/** Aggregates source/domain-specific rule providers for the generic mixing engine. */
@Component
public class RuntimeEventMixingRuleRegistry {
- private static final Set SUPPORT_EVENT_DOMAINS = Set.of(
- EventDomain.POSITION,
- EventDomain.PLACE,
- EventDomain.BORDER_CROSSING,
- EventDomain.LOAD_UNLOAD,
- EventDomain.SPECIFIC_CONDITION
- );
+ private final List providers;
- private static final Set SUPPORT_EVENT_TYPES = Set.of(
- EventType.POSITION_RECORDED,
- EventType.WORKING_DAY_PLACE_RECORDED,
- EventType.BORDER_INBOUND,
- EventType.BORDER_OUTBOUND,
- EventType.BORDER_OUT_EU,
- EventType.LOAD,
- EventType.UNLOAD,
- EventType.LOAD_UNLOAD,
- EventType.OUT,
- EventType.FERRY_TRAIN
- );
+ @Autowired
+ public RuntimeEventMixingRuleRegistry(List providers) {
+ this.providers = providers == null ? List.of() : List.copyOf(providers);
+ }
- private static final Set SUPPORT_EVENT_LIFECYCLES = Set.of(
- EventLifecycle.SNAPSHOT,
- EventLifecycle.START,
- EventLifecycle.BEGIN,
- EventLifecycle.END,
- EventLifecycle.INBOUND,
- EventLifecycle.OUTBOUND,
- EventLifecycle.OUT_EU
- );
-
- private static final Set ACTIVITY_EVENT_TYPES = Set.of(
- EventType.DRIVE,
- EventType.BREAK_REST,
- EventType.AVAILABILITY,
- EventType.WORK,
- EventType.UNKNOWN_ACTIVITY
- );
-
- private static final Set CARD_SUPPORT_EXTRACTION_CODES = Set.of(
- "CARD_POSITION",
- "CARD_PLACE",
- "CARD_BORDER_CROSSING",
- "CARD_LOAD_UNLOAD",
- "CARD_SPECIFIC_CONDITION"
- );
-
- private static final Set VU_SUPPORT_EXTRACTION_CODES = Set.of(
- "VU_POSITION",
- "VU_PLACE",
- "VU_BORDER_CROSSING",
- "VU_LOAD_UNLOAD",
- "VU_SPECIFIC_CONDITION"
- );
-
- private static final Set BOTH_TACHOGRAPH_ROLES = Set.of(
- RuntimeTachographEvidenceSourceRole.DRIVER_CARD,
- RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT
- );
public List rulesForMode(String mode) {
- if (RuntimeEventMixingService.MODE_OFF.equals(mode)) {
+ if (RuntimeEventMixingService.MODE_OFF.equalsIgnoreCase(mode)) {
return List.of();
}
- return List.of(
- tachographDbFileSessionSameRoleActivityCompatibleKey(),
- tachographDbFileSessionSameRoleSupportCompatibleKey(),
- tachographCardVuActivityExactEventKey(),
- tachographCardVuSupportExactEventKey(),
- tachographCardVuActivityCompatibleKey(),
- tachographCardVuSupportCompatibleKey()
- );
- }
-
- private RuntimeEventMixingRule tachographDbFileSessionSameRoleActivityCompatibleKey() {
- return new RuntimeEventMixingRule(
- RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_ACTIVITY_SAME_ROLE,
- RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
- RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY,
- Set.of(EventDomain.DRIVER_ACTIVITY),
- ACTIVITY_EVENT_TYPES,
- Set.of(EventLifecycle.START, EventLifecycle.END),
- Set.of(),
- Set.of(),
- BOTH_TACHOGRAPH_ROLES,
- BOTH_TACHOGRAPH_ROLES,
- Set.of(RuntimeTachographRepresentation.DATABASE),
- Set.of(RuntimeTachographRepresentation.FILE_SESSION),
- true,
- RuntimeResolvedEventRole.FUSED_PRIMARY,
- RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
- "CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
- "The tachograph database and file-session representations describe the same activity point from the same source role. The database representation is retained and the file-session representation is suppressed."
- );
- }
-
- private RuntimeEventMixingRule tachographDbFileSessionSameRoleSupportCompatibleKey() {
- return new RuntimeEventMixingRule(
- RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_SUPPORT_SAME_ROLE,
- RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
- RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY,
- SUPPORT_EVENT_DOMAINS,
- SUPPORT_EVENT_TYPES,
- SUPPORT_EVENT_LIFECYCLES,
- Set.of(),
- Set.of(),
- BOTH_TACHOGRAPH_ROLES,
- BOTH_TACHOGRAPH_ROLES,
- Set.of(RuntimeTachographRepresentation.DATABASE),
- Set.of(RuntimeTachographRepresentation.FILE_SESSION),
- true,
- RuntimeResolvedEventRole.FUSED_PRIMARY,
- RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
- "CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
- "The tachograph database and file-session representations describe the same support event from the same source role. The database representation is retained and the file-session representation is suppressed."
- );
- }
-
- private RuntimeEventMixingRule tachographCardVuActivityExactEventKey() {
- return new RuntimeEventMixingRule(
- RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_SAME_EVENT_KEY,
- RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
- RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY,
- Set.of(EventDomain.DRIVER_ACTIVITY),
- ACTIVITY_EVENT_TYPES,
- Set.of(EventLifecycle.START, EventLifecycle.END),
- Set.of("CARD_ACTIVITY"),
- Set.of("VU_ACTIVITY"),
- Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
- Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
- Set.of(),
- Set.of(),
- false,
- RuntimeResolvedEventRole.FUSED_PRIMARY,
- RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
- "FUSED_PRIMARY_SELECTED",
- "CARD_ACTIVITY and VU_ACTIVITY describe the same driver activity point. CARD_ACTIVITY is kept as primary for the activity timeline; VU_ACTIVITY is suppressed from activity intervalization."
- );
- }
-
- private RuntimeEventMixingRule tachographCardVuActivityCompatibleKey() {
- return new RuntimeEventMixingRule(
- RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_COMPATIBLE_KEY,
- RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
- RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY,
- Set.of(EventDomain.DRIVER_ACTIVITY),
- ACTIVITY_EVENT_TYPES,
- Set.of(EventLifecycle.START, EventLifecycle.END),
- Set.of("CARD_ACTIVITY"),
- Set.of("VU_ACTIVITY"),
- Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
- Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
- Set.of(),
- Set.of(),
- false,
- RuntimeResolvedEventRole.FUSED_PRIMARY,
- RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
- "FUSED_PRIMARY_SELECTED",
- "CARD_ACTIVITY and VU_ACTIVITY describe a compatible driver activity point. CARD_ACTIVITY is kept as primary for the activity timeline; VU_ACTIVITY is suppressed from activity intervalization."
- );
- }
-
- private RuntimeEventMixingRule tachographCardVuSupportExactEventKey() {
- return new RuntimeEventMixingRule(
- RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_SAME_EVENT_KEY,
- RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
- RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY,
- SUPPORT_EVENT_DOMAINS,
- SUPPORT_EVENT_TYPES,
- SUPPORT_EVENT_LIFECYCLES,
- CARD_SUPPORT_EXTRACTION_CODES,
- VU_SUPPORT_EXTRACTION_CODES,
- Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
- Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
- Set.of(),
- Set.of(),
- false,
- RuntimeResolvedEventRole.FUSED_PRIMARY,
- RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
- "FUSED_PRIMARY_SELECTED",
- "CARD and VU support evidence describe the same semantic event. CARD evidence is kept as primary support evidence; VU evidence is suppressed from support-evidence normalization but retained as audit/corroborating evidence."
- );
- }
-
- private RuntimeEventMixingRule tachographCardVuSupportCompatibleKey() {
- return new RuntimeEventMixingRule(
- RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY,
- RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
- RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY,
- SUPPORT_EVENT_DOMAINS,
- SUPPORT_EVENT_TYPES,
- SUPPORT_EVENT_LIFECYCLES,
- CARD_SUPPORT_EXTRACTION_CODES,
- VU_SUPPORT_EXTRACTION_CODES,
- Set.of(RuntimeTachographEvidenceSourceRole.DRIVER_CARD),
- Set.of(RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT),
- Set.of(),
- Set.of(),
- false,
- RuntimeResolvedEventRole.FUSED_PRIMARY,
- RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
- "FUSED_PRIMARY_SELECTED",
- "CARD and VU support evidence describe a compatible semantic event. CARD evidence is kept as primary support evidence; VU evidence is suppressed from support-evidence normalization. Vehicle/VIN identity from the VU event is copied to the primary event when the card event has weaker vehicle identity."
- );
+ return providers.stream()
+ .flatMap(provider -> provider.rulesForMode(mode).stream())
+ .toList();
}
}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingService.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingService.java
index 46f733c..8890d25 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingService.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingService.java
@@ -1,8 +1,6 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
import at.procon.eventhub.dto.EventHubEventDto;
-import at.procon.eventhub.dto.VehicleRefDto;
-import at.procon.eventhub.dto.VehicleRegistrationRefDto;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.IdentityHashMap;
@@ -19,45 +17,27 @@ import org.springframework.stereotype.Service;
public class RuntimeEventMixingService {
public static final String MODE_OFF = "OFF";
- public static final String MODE_TACHOGRAPH_SAME_SOURCE = "TACHOGRAPH_SAME_SOURCE";
public static final String MODE_FULL = "FULL";
- public static final String RULE_TACHOGRAPH_CARD_VU_ACTIVITY_SAME_EVENT_KEY =
- "tachograph.activity.card-vu.same-event-key";
- public static final String RULE_TACHOGRAPH_CARD_VU_ACTIVITY_COMPATIBLE_KEY =
- "tachograph.activity.card-vu.compatible-activity-key";
- public static final String RULE_TACHOGRAPH_CARD_VU_SUPPORT_SAME_EVENT_KEY =
- "tachograph.support.card-vu.same-event-key";
- public static final String RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY =
- "tachograph.support.card-vu.compatible-support-key";
- public static final String RULE_TACHOGRAPH_DB_FILE_SESSION_ACTIVITY_SAME_ROLE =
- "tachograph.activity.db-file-session.same-source-role";
- public static final String RULE_TACHOGRAPH_DB_FILE_SESSION_SUPPORT_SAME_ROLE =
- "tachograph.support.db-file-session.same-source-role";
private final RuntimeEventDescriptorFactory descriptorFactory;
private final RuntimeEventMixingRuleRegistry ruleRegistry;
private final RuntimeEventEvidenceCompatibilityMatcher compatibilityMatcher;
+ private final RuntimeEventFusionPolicyRegistry fusionPolicyRegistry;
@Autowired
public RuntimeEventMixingService(
RuntimeEventDescriptorFactory descriptorFactory,
RuntimeEventMixingRuleRegistry ruleRegistry,
- RuntimeEventEvidenceCompatibilityMatcher compatibilityMatcher
+ RuntimeEventEvidenceCompatibilityMatcher compatibilityMatcher,
+ RuntimeEventFusionPolicyRegistry fusionPolicyRegistry
) {
this.descriptorFactory = descriptorFactory;
this.ruleRegistry = ruleRegistry;
this.compatibilityMatcher = compatibilityMatcher;
+ this.fusionPolicyRegistry = fusionPolicyRegistry;
}
- /** Compatibility constructor used by unit tests and local registries. */
- public RuntimeEventMixingService() {
- this(
- new RuntimeEventDescriptorFactory(),
- new RuntimeEventMixingRuleRegistry(),
- new RuntimeEventEvidenceCompatibilityMatcher()
- );
- }
public RuntimeMixedEventBundle mix(List events, String requestedMode) {
String mode = normalizeMode(requestedMode);
@@ -80,8 +60,8 @@ public class RuntimeEventMixingService {
.filter(descriptorFactory::isDriverActivityPoint)
.toList();
- // Vehicle-usage events are intentionally not mixed here. CARD_VEHICLES_USED and IW_CYCLE
- // are kept as separate input evidence because they must be processed by their own rules later.
+ // Vehicle-usage input events are intentionally kept unchanged in this stage because they
+ // are reconciled by the dedicated vehicle-usage modules later in the pipeline.
List vehicleUsageEvents = rawEvents.stream()
.filter(descriptorFactory::isDriverCardUsagePoint)
.toList();
@@ -96,15 +76,10 @@ public class RuntimeEventMixingService {
notes.add("Runtime event mixing inspected " + rawEvents.size() + " event(s).");
notes.add("Runtime event mixing applied " + ruleRegistry.rulesForMode(mode).size() + " configured rule(s) in mode " + mode + ".");
notes.add("Runtime event mixing suppressed " + state.suppressedEvents().size()
- + " duplicate source event(s) from activity/support evidence channels.");
- notes.add("Runtime event mixing keeps CARD_POSITION, CARD_PLACE, and CARD_BORDER_CROSSING as primary when matching VU support evidence describes the same semantic event.");
- notes.add("Runtime event mixing kept all CARD_VEHICLES_USED and IW_CYCLE card-usage point events unchanged for vehicle-usage processing.");
- notes.add("Runtime event mixing classified " + diagnostics.driverCardSourceRoleCount()
- + " DRIVER_CARD and " + diagnostics.vehicleUnitSourceRoleCount()
- + " VEHICLE_UNIT event(s); unknown source roles=" + diagnostics.unknownSourceRoleCount() + ".");
- notes.add("Runtime event mixing classified " + diagnostics.databaseRepresentationCount()
- + " database and " + diagnostics.fileSessionRepresentationCount()
- + " file-session representation event(s).");
+ + " duplicate source event(s) from configured channels.");
+ notes.add("Source-family counts: " + diagnostics.sourceFamilyCounts() + ".");
+ notes.add("Source-role counts: " + diagnostics.sourceRoleCounts() + ".");
+ notes.add("Representation counts: " + diagnostics.representationCounts() + ".");
return new RuntimeMixedEventBundle(
rawEvents,
driverPartitionEvents,
@@ -198,7 +173,8 @@ public class RuntimeEventMixingService {
RuntimeEventDescriptor primary,
List secondaries
) {
- EventHubEventDto enrichedPrimary = enrichPrimaryVehicleRef(
+ EventHubEventDto enrichedPrimary = fusionPolicyRegistry.fuse(
+ rule,
primary.event(),
secondaries.stream().map(RuntimeEventDescriptor::event).toList()
);
@@ -246,49 +222,30 @@ public class RuntimeEventMixingService {
MixingState state
) {
List safeDescriptors = descriptors == null ? List.of() : descriptors;
- int tachographEventCount = (int) safeDescriptors.stream()
- .filter(descriptor -> descriptor.sourceProfile() != null
- && descriptor.sourceProfile().isTachographRuntimeSource())
- .count();
- int driverCardCount = (int) safeDescriptors.stream()
- .filter(descriptor -> descriptor.evidenceSourceRole()
- == RuntimeTachographEvidenceSourceRole.DRIVER_CARD)
- .count();
- int vehicleUnitCount = (int) safeDescriptors.stream()
- .filter(descriptor -> descriptor.evidenceSourceRole()
- == RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT)
- .count();
- int unknownRoleCount = (int) safeDescriptors.stream()
- .filter(descriptor -> descriptor.evidenceSourceRole()
- == RuntimeTachographEvidenceSourceRole.UNKNOWN)
- .count();
- int databaseCount = (int) safeDescriptors.stream()
- .filter(descriptor -> descriptor.representation()
- == RuntimeTachographRepresentation.DATABASE)
- .count();
- int fileSessionCount = (int) safeDescriptors.stream()
- .filter(descriptor -> descriptor.representation()
- == RuntimeTachographRepresentation.FILE_SESSION)
- .count();
- int unknownRepresentationCount = (int) safeDescriptors.stream()
- .filter(descriptor -> descriptor.representation()
- == RuntimeTachographRepresentation.UNKNOWN)
- .count();
return new RuntimeEventMixingDiagnostics(
safeDescriptors.size(),
- tachographEventCount,
- driverCardCount,
- vehicleUnitCount,
- unknownRoleCount,
- databaseCount,
- fileSessionCount,
- unknownRepresentationCount,
+ classificationCounts(safeDescriptors, RuntimeEventClassificationKeys.SOURCE_FAMILY),
+ classificationCounts(safeDescriptors, RuntimeEventClassificationKeys.SOURCE_ROLE),
+ classificationCounts(safeDescriptors, RuntimeEventClassificationKeys.REPRESENTATION),
state == null ? 0 : state.candidateGroupCount(),
state == null ? 0 : state.compatibilityRejectedCount(),
state == null ? 0 : state.suppressedEvents().size()
);
}
+ private Map classificationCounts(
+ List descriptors,
+ String classificationKey
+ ) {
+ Map counts = new LinkedHashMap<>();
+ for (RuntimeEventDescriptor descriptor : descriptors) {
+ String value = descriptor.classification(classificationKey);
+ String normalized = value == null || value.isBlank() ? "UNKNOWN" : value;
+ counts.merge(normalized, 1, Integer::sum);
+ }
+ return Map.copyOf(counts);
+ }
+
private RuntimeResolvedEvent defaultResolvedEvent(RuntimeEventDescriptor descriptor) {
RuntimeEventMixingChannel channel = defaultChannel(descriptor);
RuntimeResolvedEventRole role = switch (channel) {
@@ -317,7 +274,7 @@ public class RuntimeEventMixingService {
if (descriptor.driverActivityPoint()) {
return RuntimeEventMixingChannel.ACTIVITY_TIMELINE;
}
- if (descriptor.driverCardUsagePoint()) {
+ if (descriptor.vehicleUsageInputCandidate()) {
return RuntimeEventMixingChannel.VEHICLE_USAGE;
}
return RuntimeEventMixingChannel.SUPPORT_EVIDENCE;
@@ -332,101 +289,11 @@ public class RuntimeEventMixingService {
.thenComparing(descriptor -> descriptor.event() == null ? null : descriptor.event().externalSourceEventId(), Comparator.nullsLast(String::compareTo));
}
- private static EventHubEventDto enrichPrimaryVehicleRef(EventHubEventDto primary, List secondaries) {
- if (primary == null || secondaries == null || secondaries.isEmpty()) {
- return primary;
- }
- VehicleRefDto bestSecondary = secondaries.stream()
- .map(EventHubEventDto::vehicleRef)
- .filter(Objects::nonNull)
- .filter(VehicleRefDto::hasAnyReference)
- .filter(RuntimeEventMixingService::hasVehicleIdentity)
- .findFirst()
- .orElse(null);
- if (bestSecondary == null || !shouldEnrichVehicleRef(primary.vehicleRef(), bestSecondary)) {
- return primary;
- }
- VehicleRefDto merged = mergeVehicleRef(primary.vehicleRef(), bestSecondary);
- if (Objects.equals(primary.vehicleRef(), merged)) {
- return primary;
- }
- return new EventHubEventDto(
- primary.eventId(),
- primary.externalSourceEventId(),
- primary.driverRef(),
- merged,
- primary.occurredAt(),
- primary.receivedPartnerAt(),
- primary.receivedHubAt(),
- primary.eventDomain(),
- primary.eventType(),
- primary.lifecycle(),
- primary.odometerM(),
- primary.position(),
- primary.eventDetails(),
- primary.sourcePackageRef(),
- primary.payload(),
- primary.manualEntry(),
- primary.packageInfo()
- );
- }
-
- private static boolean shouldEnrichVehicleRef(VehicleRefDto primary, VehicleRefDto secondary) {
- return secondary != null
- && hasVehicleIdentity(secondary)
- && (primary == null || !hasVehicleIdentity(primary));
- }
-
- private static boolean hasVehicleIdentity(VehicleRefDto vehicleRef) {
- return vehicleRef != null
- && (notBlank(vehicleRef.sourceVehicleEntityId()) || notBlank(vehicleRef.vin()));
- }
-
- private static VehicleRefDto mergeVehicleRef(VehicleRefDto primary, VehicleRefDto secondary) {
- if (primary == null) {
- return secondary;
- }
- if (secondary == null) {
- return primary;
- }
- VehicleRegistrationRefDto registration = primary.vehicleRegistration() != null && primary.vehicleRegistration().hasValue()
- ? primary.vehicleRegistration()
- : secondary.vehicleRegistration();
- return new VehicleRefDto(
- firstNonBlank(primary.sourceVehicleEntityId(), secondary.sourceVehicleEntityId()),
- firstNonBlank(primary.vin(), secondary.vin()),
- firstNonBlank(primary.sourceRegistrationEntityId(), secondary.sourceRegistrationEntityId()),
- registration
- );
- }
-
private static String normalizeMode(String requestedMode) {
String value = requestedMode == null || requestedMode.isBlank()
- ? null
+ ? MODE_FULL
: requestedMode.trim().toUpperCase(java.util.Locale.ROOT);
- if (value == null) {
- return MODE_TACHOGRAPH_SAME_SOURCE;
- }
- return switch (value) {
- case MODE_OFF, MODE_TACHOGRAPH_SAME_SOURCE, MODE_FULL -> value;
- default -> MODE_TACHOGRAPH_SAME_SOURCE;
- };
- }
-
- private static boolean notBlank(String value) {
- return value != null && !value.isBlank();
- }
-
- private static String firstNonBlank(String... values) {
- if (values == null) {
- return null;
- }
- for (String value : values) {
- if (value != null && !value.isBlank()) {
- return value.trim();
- }
- }
- return null;
+ return MODE_OFF.equals(value) ? MODE_OFF : value;
}
private static final class MixingState {
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventPairConstraint.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventPairConstraint.java
new file mode 100644
index 0000000..cb7ca2e
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventPairConstraint.java
@@ -0,0 +1,32 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import java.util.Set;
+
+/** Generic constraints evaluated between the selected primary and secondary descriptors. */
+public record RuntimeEventPairConstraint(Set equalClassificationKeys) {
+
+ public static final RuntimeEventPairConstraint NONE = new RuntimeEventPairConstraint(Set.of());
+
+ public RuntimeEventPairConstraint {
+ equalClassificationKeys = equalClassificationKeys == null
+ ? Set.of()
+ : equalClassificationKeys.stream()
+ .filter(key -> key != null && !key.isBlank())
+ .map(String::trim)
+ .collect(java.util.stream.Collectors.toUnmodifiableSet());
+ }
+
+ public boolean matches(RuntimeEventDescriptor primary, RuntimeEventDescriptor secondary) {
+ if (primary == null || secondary == null) {
+ return false;
+ }
+ for (String key : equalClassificationKeys) {
+ String left = RuntimeEventSelector.normalizeValue(primary.classification(key));
+ String right = RuntimeEventSelector.normalizeValue(secondary.classification(key));
+ if (left == null || right == null || !left.equals(right)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSelector.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSelector.java
new file mode 100644
index 0000000..c616d4d
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSelector.java
@@ -0,0 +1,68 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/** Generic selector for one side of a mixing rule. */
+public record RuntimeEventSelector(Map> acceptedClassifications) {
+
+ public static final RuntimeEventSelector ANY = new RuntimeEventSelector(Map.of());
+
+ public RuntimeEventSelector {
+ Map> normalized = new LinkedHashMap<>();
+ if (acceptedClassifications != null) {
+ acceptedClassifications.forEach((key, values) -> {
+ String normalizedKey = normalizeKey(key);
+ Set normalizedValues = normalizeValues(values);
+ if (normalizedKey != null && !normalizedValues.isEmpty()) {
+ normalized.put(normalizedKey, normalizedValues);
+ }
+ });
+ }
+ acceptedClassifications = Map.copyOf(normalized);
+ }
+
+ public boolean matches(RuntimeEventDescriptor descriptor) {
+ if (descriptor == null) {
+ return false;
+ }
+ for (Map.Entry> entry : acceptedClassifications.entrySet()) {
+ String actual = normalizeValue(descriptor.classification(entry.getKey()));
+ if (actual == null || !entry.getValue().contains(actual)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ public static RuntimeEventSelector of(String key, Set values) {
+ return new RuntimeEventSelector(Map.of(key, values));
+ }
+
+ public static RuntimeEventSelector of(Map> classifications) {
+ return new RuntimeEventSelector(classifications);
+ }
+
+ private static Set normalizeValues(Set values) {
+ if (values == null || values.isEmpty()) {
+ return Set.of();
+ }
+ return values.stream()
+ .map(RuntimeEventSelector::normalizeValue)
+ .filter(value -> value != null)
+ .collect(Collectors.toUnmodifiableSet());
+ }
+
+ private static String normalizeKey(String value) {
+ return value == null || value.isBlank() ? null : value.trim();
+ }
+
+ static String normalizeValue(String value) {
+ return value == null || value.isBlank()
+ ? null
+ : value.trim().toUpperCase(Locale.ROOT);
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSemantics.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSemantics.java
new file mode 100644
index 0000000..58a633f
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSemantics.java
@@ -0,0 +1,15 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import at.procon.eventhub.dto.EventHubEventDto;
+
+/** Source-specific semantic adapter used by the generic descriptor factory. */
+public interface RuntimeEventSemantics {
+
+ boolean supports(EventHubEventDto event);
+
+ RuntimeEventSourceProfile sourceProfile(EventHubEventDto event);
+
+ default String semanticLifecycle(EventHubEventDto event) {
+ return event == null || event.lifecycle() == null ? null : event.lifecycle().name();
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSourceProfile.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSourceProfile.java
index 6a6a7c7..76f2f10 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSourceProfile.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventSourceProfile.java
@@ -1,40 +1,44 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Generic source profile used by runtime event mixing.
+ *
+ * Source-specific adapters publish opaque classification values such as source family, role,
+ * representation and extraction code. The common mixing engine never depends on domain-specific
+ * enums.
+ */
public record RuntimeEventSourceProfile(
String sourceSystem,
String sourceKind,
String extractionCode,
- RuntimeTachographEvidenceSourceRole evidenceSourceRole,
- RuntimeTachographRepresentation representation
+ Map classifications
) {
- /** Compatibility constructor retained for existing tests and direct callers. */
- public RuntimeEventSourceProfile(
- String sourceSystem,
- String sourceKind,
- String extractionCode
- ) {
- this(
- sourceSystem,
- sourceKind,
- extractionCode,
- RuntimeTachographEvidenceSourceRole.UNKNOWN,
- RuntimeTachographRepresentation.UNKNOWN
- );
+ public RuntimeEventSourceProfile(String sourceSystem, String sourceKind, String extractionCode) {
+ this(sourceSystem, sourceKind, extractionCode, Map.of());
}
public RuntimeEventSourceProfile {
- evidenceSourceRole = evidenceSourceRole == null
- ? RuntimeTachographEvidenceSourceRole.UNKNOWN
- : evidenceSourceRole;
- representation = representation == null
- ? RuntimeTachographRepresentation.UNKNOWN
- : representation;
+ Map normalized = new LinkedHashMap<>();
+ if (classifications != null) {
+ classifications.forEach((key, value) -> {
+ if (key != null && !key.isBlank() && value != null && !value.isBlank()) {
+ normalized.put(key.trim(), value.trim().toUpperCase(java.util.Locale.ROOT));
+ }
+ });
+ }
+ if (extractionCode != null && !extractionCode.isBlank()) {
+ normalized.putIfAbsent(
+ RuntimeEventClassificationKeys.EXTRACTION_CODE,
+ extractionCode.trim().toUpperCase(java.util.Locale.ROOT)
+ );
+ }
+ classifications = Map.copyOf(normalized);
}
- public boolean isTachographRuntimeSource() {
- return switch (sourceSystem == null ? "" : sourceSystem) {
- case "TACHOGRAPH", "TACHOGRAPH_FILE_SESSION", "COMPOSITE_TACHOGRAPH_FILE_SESSION" -> true;
- default -> false;
- };
+ public String classification(String key) {
+ return key == null ? null : classifications.get(key);
}
}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeMixedEventBundle.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeMixedEventBundle.java
index f77adcf..1bf8620 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeMixedEventBundle.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeMixedEventBundle.java
@@ -26,7 +26,7 @@ public record RuntimeMixedEventBundle(
resolvedEvents = resolvedEvents == null ? List.of() : List.copyOf(resolvedEvents);
eventMixingDecisions = eventMixingDecisions == null ? List.of() : List.copyOf(eventMixingDecisions);
diagnostics = diagnostics == null
- ? new RuntimeEventMixingDiagnostics(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
+ ? new RuntimeEventMixingDiagnostics(0, java.util.Map.of(), java.util.Map.of(), java.util.Map.of(), 0, 0, 0)
: diagnostics;
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographActivityCompatibilityPolicy.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographActivityCompatibilityPolicy.java
new file mode 100644
index 0000000..2810c8d
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographActivityCompatibilityPolicy.java
@@ -0,0 +1,30 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class RuntimeTachographActivityCompatibilityPolicy implements RuntimeEventCompatibilityPolicy {
+
+ private final RuntimeTachographEvidenceCompatibilityPolicy delegate;
+
+ public RuntimeTachographActivityCompatibilityPolicy(
+ RuntimeTachographEvidenceCompatibilityPolicy delegate
+ ) {
+ this.delegate = delegate;
+ }
+
+ /** Compatibility constructor used by unit tests. */
+ public RuntimeTachographActivityCompatibilityPolicy() {
+ this(new RuntimeTachographEvidenceCompatibilityPolicy());
+ }
+
+ @Override
+ public String policyId() {
+ return RuntimeTachographEvidenceCompatibilityPolicy.ACTIVITY_POLICY_ID;
+ }
+
+ @Override
+ public boolean compatible(RuntimeEventDescriptor primary, RuntimeEventDescriptor secondary) {
+ return delegate.activityCompatible(primary, secondary);
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEventMixingRuleProvider.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEventMixingRuleProvider.java
new file mode 100644
index 0000000..40fe65b
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEventMixingRuleProvider.java
@@ -0,0 +1,262 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import at.procon.eventhub.dto.EventDomain;
+import at.procon.eventhub.dto.EventLifecycle;
+import at.procon.eventhub.dto.EventType;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.stereotype.Component;
+
+/** Tachograph-specific rules expressed through generic selectors and policies. */
+@Component
+public class RuntimeTachographEventMixingRuleProvider implements RuntimeEventMixingRuleProvider {
+
+ public static final String LEGACY_MODE_TACHOGRAPH_SAME_SOURCE = "TACHOGRAPH_SAME_SOURCE";
+
+ public static final String RULE_CARD_VU_ACTIVITY_SAME_EVENT_KEY =
+ "tachograph.activity.card-vu.same-event-key";
+ public static final String RULE_CARD_VU_ACTIVITY_COMPATIBLE_KEY =
+ "tachograph.activity.card-vu.compatible-activity-key";
+ public static final String RULE_CARD_VU_SUPPORT_SAME_EVENT_KEY =
+ "tachograph.support.card-vu.same-event-key";
+ public static final String RULE_CARD_VU_SUPPORT_COMPATIBLE_KEY =
+ "tachograph.support.card-vu.compatible-support-key";
+ public static final String RULE_DB_FILE_SESSION_ACTIVITY_SAME_ROLE =
+ "tachograph.activity.db-file-session.same-source-role";
+ public static final String RULE_DB_FILE_SESSION_SUPPORT_SAME_ROLE =
+ "tachograph.support.db-file-session.same-source-role";
+
+ private static final String FAMILY_TACHOGRAPH = "TACHOGRAPH";
+ private static final String ROLE_DRIVER_CARD = "DRIVER_CARD";
+ private static final String ROLE_VEHICLE_UNIT = "VEHICLE_UNIT";
+ private static final String REPRESENTATION_DATABASE = "DATABASE";
+ private static final String REPRESENTATION_FILE_SESSION = "FILE_SESSION";
+
+ private static final Set SUPPORT_EVENT_DOMAINS = Set.of(
+ EventDomain.POSITION,
+ EventDomain.PLACE,
+ EventDomain.BORDER_CROSSING,
+ EventDomain.LOAD_UNLOAD,
+ EventDomain.SPECIFIC_CONDITION
+ );
+
+ private static final Set SUPPORT_EVENT_TYPES = Set.of(
+ EventType.POSITION_RECORDED,
+ EventType.WORKING_DAY_PLACE_RECORDED,
+ EventType.BORDER_INBOUND,
+ EventType.BORDER_OUTBOUND,
+ EventType.BORDER_OUT_EU,
+ EventType.LOAD,
+ EventType.UNLOAD,
+ EventType.LOAD_UNLOAD,
+ EventType.OUT,
+ EventType.FERRY_TRAIN
+ );
+
+ private static final Set SUPPORT_EVENT_LIFECYCLES = Set.of(
+ EventLifecycle.SNAPSHOT,
+ EventLifecycle.START,
+ EventLifecycle.BEGIN,
+ EventLifecycle.END,
+ EventLifecycle.INBOUND,
+ EventLifecycle.OUTBOUND,
+ EventLifecycle.OUT_EU
+ );
+
+ private static final Set ACTIVITY_EVENT_TYPES = Set.of(
+ EventType.DRIVE,
+ EventType.BREAK_REST,
+ EventType.AVAILABILITY,
+ EventType.WORK,
+ EventType.UNKNOWN_ACTIVITY
+ );
+
+ private static final Set CARD_SUPPORT_EXTRACTION_CODES = Set.of(
+ "CARD_POSITION",
+ "CARD_PLACE",
+ "CARD_BORDER_CROSSING",
+ "CARD_LOAD_UNLOAD",
+ "CARD_SPECIFIC_CONDITION"
+ );
+
+ private static final Set VU_SUPPORT_EXTRACTION_CODES = Set.of(
+ "VU_POSITION",
+ "VU_PLACE",
+ "VU_BORDER_CROSSING",
+ "VU_LOAD_UNLOAD",
+ "VU_SPECIFIC_CONDITION"
+ );
+
+ @Override
+ public List rulesForMode(String mode) {
+ String normalizedMode = mode == null ? RuntimeEventMixingService.MODE_FULL : mode.trim().toUpperCase(java.util.Locale.ROOT);
+ if (!RuntimeEventMixingService.MODE_FULL.equals(normalizedMode)
+ && !LEGACY_MODE_TACHOGRAPH_SAME_SOURCE.equals(normalizedMode)) {
+ return List.of();
+ }
+ return List.of(
+ dbFileSessionSameRoleActivity(),
+ dbFileSessionSameRoleSupport(),
+ cardVuActivityExact(),
+ cardVuSupportExact(),
+ cardVuActivityCompatible(),
+ cardVuSupportCompatible()
+ );
+ }
+
+ private RuntimeEventMixingRule dbFileSessionSameRoleActivity() {
+ return rule(
+ RULE_DB_FILE_SESSION_ACTIVITY_SAME_ROLE,
+ RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
+ RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY,
+ RuntimeTachographEvidenceCompatibilityPolicy.ACTIVITY_POLICY_ID,
+ Set.of(EventDomain.DRIVER_ACTIVITY),
+ ACTIVITY_EVENT_TYPES,
+ Set.of(EventLifecycle.START, EventLifecycle.END),
+ selector(Set.of(ROLE_DRIVER_CARD, ROLE_VEHICLE_UNIT), Set.of(REPRESENTATION_DATABASE), Set.of()),
+ selector(Set.of(ROLE_DRIVER_CARD, ROLE_VEHICLE_UNIT), Set.of(REPRESENTATION_FILE_SESSION), Set.of()),
+ new RuntimeEventPairConstraint(Set.of(RuntimeEventClassificationKeys.SOURCE_ROLE)),
+ "CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
+ "Database and file-session representations describe the same activity point from the same semantic source role."
+ );
+ }
+
+ private RuntimeEventMixingRule dbFileSessionSameRoleSupport() {
+ return rule(
+ RULE_DB_FILE_SESSION_SUPPORT_SAME_ROLE,
+ RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
+ RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY,
+ RuntimeTachographEvidenceCompatibilityPolicy.SUPPORT_POLICY_ID,
+ SUPPORT_EVENT_DOMAINS,
+ SUPPORT_EVENT_TYPES,
+ SUPPORT_EVENT_LIFECYCLES,
+ selector(Set.of(ROLE_DRIVER_CARD, ROLE_VEHICLE_UNIT), Set.of(REPRESENTATION_DATABASE), Set.of()),
+ selector(Set.of(ROLE_DRIVER_CARD, ROLE_VEHICLE_UNIT), Set.of(REPRESENTATION_FILE_SESSION), Set.of()),
+ new RuntimeEventPairConstraint(Set.of(RuntimeEventClassificationKeys.SOURCE_ROLE)),
+ "CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
+ "Database and file-session representations describe the same support event from the same semantic source role."
+ );
+ }
+
+ private RuntimeEventMixingRule cardVuActivityExact() {
+ return rule(
+ RULE_CARD_VU_ACTIVITY_SAME_EVENT_KEY,
+ RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
+ RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY,
+ RuntimeEventMixingRule.COMPATIBILITY_ALWAYS,
+ Set.of(EventDomain.DRIVER_ACTIVITY),
+ ACTIVITY_EVENT_TYPES,
+ Set.of(EventLifecycle.START, EventLifecycle.END),
+ selector(Set.of(ROLE_DRIVER_CARD), Set.of(), Set.of("CARD_ACTIVITY")),
+ selector(Set.of(ROLE_VEHICLE_UNIT), Set.of(), Set.of("VU_ACTIVITY")),
+ RuntimeEventPairConstraint.NONE,
+ "FUSED_PRIMARY_SELECTED",
+ "Driver-card and vehicle-unit evidence describe the same driver activity point."
+ );
+ }
+
+ private RuntimeEventMixingRule cardVuActivityCompatible() {
+ return rule(
+ RULE_CARD_VU_ACTIVITY_COMPATIBLE_KEY,
+ RuntimeEventMixingChannel.ACTIVITY_TIMELINE,
+ RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_ACTIVITY_KEY,
+ RuntimeTachographEvidenceCompatibilityPolicy.ACTIVITY_POLICY_ID,
+ Set.of(EventDomain.DRIVER_ACTIVITY),
+ ACTIVITY_EVENT_TYPES,
+ Set.of(EventLifecycle.START, EventLifecycle.END),
+ selector(Set.of(ROLE_DRIVER_CARD), Set.of(), Set.of("CARD_ACTIVITY")),
+ selector(Set.of(ROLE_VEHICLE_UNIT), Set.of(), Set.of("VU_ACTIVITY")),
+ RuntimeEventPairConstraint.NONE,
+ "FUSED_PRIMARY_SELECTED",
+ "Driver-card and vehicle-unit evidence describe a compatible driver activity point."
+ );
+ }
+
+ private RuntimeEventMixingRule cardVuSupportExact() {
+ return rule(
+ RULE_CARD_VU_SUPPORT_SAME_EVENT_KEY,
+ RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
+ RuntimeEventMixingRule.EQUIVALENCE_EXACT_EVENT_KEY,
+ RuntimeEventMixingRule.COMPATIBILITY_ALWAYS,
+ SUPPORT_EVENT_DOMAINS,
+ SUPPORT_EVENT_TYPES,
+ SUPPORT_EVENT_LIFECYCLES,
+ selector(Set.of(ROLE_DRIVER_CARD), Set.of(), CARD_SUPPORT_EXTRACTION_CODES),
+ selector(Set.of(ROLE_VEHICLE_UNIT), Set.of(), VU_SUPPORT_EXTRACTION_CODES),
+ RuntimeEventPairConstraint.NONE,
+ "FUSED_PRIMARY_SELECTED",
+ "Driver-card and vehicle-unit support evidence describe the same semantic event."
+ );
+ }
+
+ private RuntimeEventMixingRule cardVuSupportCompatible() {
+ return rule(
+ RULE_CARD_VU_SUPPORT_COMPATIBLE_KEY,
+ RuntimeEventMixingChannel.SUPPORT_EVIDENCE,
+ RuntimeEventMixingRule.EQUIVALENCE_COMPATIBLE_SUPPORT_KEY,
+ RuntimeTachographEvidenceCompatibilityPolicy.SUPPORT_POLICY_ID,
+ SUPPORT_EVENT_DOMAINS,
+ SUPPORT_EVENT_TYPES,
+ SUPPORT_EVENT_LIFECYCLES,
+ selector(Set.of(ROLE_DRIVER_CARD), Set.of(), CARD_SUPPORT_EXTRACTION_CODES),
+ selector(Set.of(ROLE_VEHICLE_UNIT), Set.of(), VU_SUPPORT_EXTRACTION_CODES),
+ RuntimeEventPairConstraint.NONE,
+ "FUSED_PRIMARY_SELECTED",
+ "Driver-card and vehicle-unit support evidence describe a compatible semantic event."
+ );
+ }
+
+ private RuntimeEventMixingRule rule(
+ String ruleId,
+ RuntimeEventMixingChannel channel,
+ String equivalenceType,
+ String compatibilityPolicyId,
+ Set domains,
+ Set types,
+ Set lifecycles,
+ RuntimeEventSelector primary,
+ RuntimeEventSelector secondary,
+ RuntimeEventPairConstraint pairConstraint,
+ String decision,
+ String reason
+ ) {
+ return new RuntimeEventMixingRule(
+ ruleId,
+ channel,
+ equivalenceType,
+ compatibilityPolicyId,
+ RuntimeTachographVehicleIdentityFusionPolicy.POLICY_ID,
+ domains,
+ types,
+ lifecycles,
+ primary,
+ secondary,
+ pairConstraint,
+ RuntimeResolvedEventRole.FUSED_PRIMARY,
+ RuntimeResolvedEventRole.SUPPRESSED_DUPLICATE,
+ decision,
+ reason
+ );
+ }
+
+ private RuntimeEventSelector selector(
+ Set roles,
+ Set representations,
+ Set extractionCodes
+ ) {
+ Map> values = new LinkedHashMap<>();
+ values.put(RuntimeEventClassificationKeys.SOURCE_FAMILY, Set.of(FAMILY_TACHOGRAPH));
+ if (roles != null && !roles.isEmpty()) {
+ values.put(RuntimeEventClassificationKeys.SOURCE_ROLE, roles);
+ }
+ if (representations != null && !representations.isEmpty()) {
+ values.put(RuntimeEventClassificationKeys.REPRESENTATION, representations);
+ }
+ if (extractionCodes != null && !extractionCodes.isEmpty()) {
+ values.put(RuntimeEventClassificationKeys.EXTRACTION_CODE, extractionCodes);
+ }
+ return RuntimeEventSelector.of(values);
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEventSemantics.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEventSemantics.java
index 02121aa..b346591 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEventSemantics.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEventSemantics.java
@@ -5,7 +5,9 @@ import at.procon.eventhub.dto.EventHubEventDto;
import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
import com.fasterxml.jackson.databind.JsonNode;
+import java.util.LinkedHashMap;
import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.springframework.stereotype.Component;
@@ -18,7 +20,7 @@ import org.springframework.stereotype.Component;
* differences relevant to runtime mixing and leaves the original event untouched.
*/
@Component
-public class RuntimeTachographEventSemantics {
+public class RuntimeTachographEventSemantics implements RuntimeEventSemantics {
private static final Set TACHOGRAPH_SOURCE_SYSTEMS = Set.of(
"TACHOGRAPH",
@@ -44,6 +46,7 @@ public class RuntimeTachographEventSemantics {
"SPEEDING_EVENTS"
);
+ @Override
public RuntimeEventSourceProfile sourceProfile(EventHubEventDto event) {
JsonNode raw = rawPayload(event);
String explicitExtractionCode = normalizeUpper(firstNonBlank(
@@ -86,12 +89,24 @@ public class RuntimeTachographEventSemantics {
? representation(event)
: RuntimeTachographRepresentation.UNKNOWN;
+ Map classifications = new LinkedHashMap<>();
+ if (tachograph) {
+ classifications.put(RuntimeEventClassificationKeys.SOURCE_FAMILY, "TACHOGRAPH");
+ }
+ if (sourceRole != RuntimeTachographEvidenceSourceRole.UNKNOWN) {
+ classifications.put(RuntimeEventClassificationKeys.SOURCE_ROLE, sourceRole.name());
+ }
+ if (representation != RuntimeTachographRepresentation.UNKNOWN) {
+ classifications.put(RuntimeEventClassificationKeys.REPRESENTATION, representation.name());
+ }
+ if (extractionCode != null) {
+ classifications.put(RuntimeEventClassificationKeys.EXTRACTION_CODE, extractionCode);
+ }
return new RuntimeEventSourceProfile(
sourceSystem,
sourceKind,
extractionCode,
- sourceRole,
- representation
+ classifications
);
}
@@ -99,6 +114,7 @@ public class RuntimeTachographEventSemantics {
* Returns a semantic lifecycle used only for equivalence matching.
* DB place events use START while file-session place events use BEGIN for the same fact.
*/
+ @Override
public String semanticLifecycle(EventHubEventDto event) {
if (event == null || event.lifecycle() == null) {
return null;
@@ -155,7 +171,9 @@ public class RuntimeTachographEventSemantics {
}
public RuntimeTachographEvidenceSourceRole evidenceSourceRole(EventHubEventDto event) {
- return sourceProfile(event).evidenceSourceRole();
+ RuntimeEventSourceProfile profile = sourceProfile(event);
+ String value = profile.classification(RuntimeEventClassificationKeys.SOURCE_ROLE);
+ return roleFromToken(value);
}
public RuntimeTachographRepresentation representation(EventHubEventDto event) {
@@ -198,8 +216,14 @@ public class RuntimeTachographEventSemantics {
return RuntimeTachographRepresentation.UNKNOWN;
}
+ @Override
+ public boolean supports(EventHubEventDto event) {
+ RuntimeEventSourceProfile profile = sourceProfile(event);
+ return "TACHOGRAPH".equals(profile.classification(RuntimeEventClassificationKeys.SOURCE_FAMILY));
+ }
+
public boolean isTachographRepresentation(EventHubEventDto event) {
- return sourceProfile(event).isTachographRuntimeSource();
+ return supports(event);
}
private RuntimeTachographEvidenceSourceRole evidenceSourceRole(
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEvidenceCompatibilityPolicy.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEvidenceCompatibilityPolicy.java
new file mode 100644
index 0000000..c70e316
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographEvidenceCompatibilityPolicy.java
@@ -0,0 +1,259 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import at.procon.eventhub.dto.EventHubEventDto;
+import at.procon.eventhub.dto.GeoPointDto;
+import at.procon.eventhub.dto.VehicleRefDto;
+import at.procon.eventhub.dto.VehicleRegistrationRefDto;
+import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
+import at.procon.eventhub.reference.TachographNationRegistry;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.math.BigDecimal;
+import java.util.Locale;
+import java.util.Objects;
+import org.springframework.stereotype.Component;
+
+/** Tachograph-specific compatibility policy used by generic runtime mixing rules. */
+@Component
+public class RuntimeTachographEvidenceCompatibilityPolicy implements RuntimeEventCompatibilityPolicy {
+
+ public static final String ACTIVITY_POLICY_ID = "TACHOGRAPH_ACTIVITY_COMPATIBILITY";
+ public static final String SUPPORT_POLICY_ID = "TACHOGRAPH_SUPPORT_COMPATIBILITY";
+
+ private static final BigDecimal GEO_TOLERANCE = new BigDecimal("0.000000001");
+
+ @Override
+ public String policyId() {
+ // The policy registry supports one id per bean. This bean exposes the support policy id;
+ // activity is delegated by the small activity policy below.
+ return SUPPORT_POLICY_ID;
+ }
+
+ @Override
+ public boolean compatible(RuntimeEventDescriptor primary, RuntimeEventDescriptor secondary) {
+ return supportCompatible(primary == null ? null : primary.event(),
+ secondary == null ? null : secondary.event());
+ }
+
+ public boolean activityCompatible(RuntimeEventDescriptor primary, RuntimeEventDescriptor secondary) {
+ return activityCompatible(primary == null ? null : primary.event(),
+ secondary == null ? null : secondary.event());
+ }
+
+ private boolean activityCompatible(EventHubEventDto left, EventHubEventDto right) {
+ return tenantCompatible(left, right)
+ && registrationCompatible(left, right)
+ && vehicleIdentityCompatible(left, right)
+ && optionalTokenCompatible(activitySlot(left), activitySlot(right));
+ }
+
+ private boolean supportCompatible(EventHubEventDto left, EventHubEventDto right) {
+ return tenantCompatible(left, right)
+ && registrationCompatible(left, right)
+ && vehicleIdentityCompatible(left, right)
+ && coordinatesCompatible(left, right)
+ && odometerCompatible(left, right)
+ && nationCompatible(detailValue(left, "country"), detailValue(right, "country"))
+ && regionCompatible(detailValue(left, "region"), detailValue(right, "region"))
+ && nationCompatible(detailValue(left, "countryFrom"), detailValue(right, "countryFrom"))
+ && nationCompatible(detailValue(left, "countryTo"), detailValue(right, "countryTo"))
+ && optionalTokenCompatible(detailValue(left, "operation"), detailValue(right, "operation"));
+ }
+
+ private boolean tenantCompatible(EventHubEventDto left, EventHubEventDto right) {
+ return optionalTokenCompatible(normalizedTenant(left), normalizedTenant(right));
+ }
+
+ private String normalizedTenant(EventHubEventDto event) {
+ String value = event == null || event.packageInfo() == null ? null : event.packageInfo().tenantKey();
+ String normalized = normalizeToken(value);
+ return Objects.equals("DEFAULT", normalized) ? null : normalized;
+ }
+
+ private boolean registrationCompatible(EventHubEventDto left, EventHubEventDto right) {
+ return optionalTokenCompatible(normalizedRegistration(left), normalizedRegistration(right));
+ }
+
+ private String normalizedRegistration(EventHubEventDto event) {
+ VehicleRefDto vehicleRef = event == null ? null : event.vehicleRef();
+ VehicleRegistrationRefDto registration = vehicleRef == null ? null : vehicleRef.vehicleRegistration();
+ if (registration != null && registration.hasValue()) {
+ String nation = normalizedNation(registration.nation(), registration.nationNumericCode());
+ String number = normalizeIdentifier(registration.number());
+ return nullToEmpty(nation) + ":" + nullToEmpty(number);
+ }
+
+ String key = RuntimeEntityReferenceResolver.registrationKey(event);
+ if (key == null || key.isBlank()) {
+ return null;
+ }
+ int separator = key.indexOf(':');
+ if (separator < 0) {
+ return normalizeIdentifier(key);
+ }
+ String nation = normalizedNation(key.substring(0, separator), null);
+ String number = normalizeIdentifier(key.substring(separator + 1));
+ return nullToEmpty(nation) + ":" + nullToEmpty(number);
+ }
+
+ private boolean vehicleIdentityCompatible(EventHubEventDto left, EventHubEventDto right) {
+ String leftVin = normalizedVin(left);
+ String rightVin = normalizedVin(right);
+ return optionalTokenCompatible(leftVin, rightVin);
+ }
+
+ private String normalizedVin(EventHubEventDto event) {
+ String vehicleKey = RuntimeEntityReferenceResolver.vehicleKey(event);
+ if (vehicleKey != null) {
+ return normalizeIdentifier(vehicleKey);
+ }
+ VehicleRefDto vehicleRef = event == null ? null : event.vehicleRef();
+ return vehicleRef == null ? null : normalizeIdentifier(vehicleRef.vin());
+ }
+
+ private boolean coordinatesCompatible(EventHubEventDto left, EventHubEventDto right) {
+ BigDecimal leftLatitude = coordinate(left, true);
+ BigDecimal rightLatitude = coordinate(right, true);
+ BigDecimal leftLongitude = coordinate(left, false);
+ BigDecimal rightLongitude = coordinate(right, false);
+ return optionalDecimalCompatible(leftLatitude, rightLatitude, GEO_TOLERANCE)
+ && optionalDecimalCompatible(leftLongitude, rightLongitude, GEO_TOLERANCE);
+ }
+
+ private BigDecimal coordinate(EventHubEventDto event, boolean latitude) {
+ GeoPointDto position = event == null ? null : event.position();
+ BigDecimal value = position == null ? null : latitude ? position.latitude() : position.longitude();
+ if (value != null) {
+ return value;
+ }
+ return decimal(rawValue(event, latitude ? "latitude" : "longitude"));
+ }
+
+ private boolean odometerCompatible(EventHubEventDto left, EventHubEventDto right) {
+ BigDecimal leftValue = odometerM(left);
+ BigDecimal rightValue = odometerM(right);
+ return optionalDecimalCompatible(leftValue, rightValue, BigDecimal.ZERO);
+ }
+
+ private BigDecimal odometerM(EventHubEventDto event) {
+ if (event != null && event.odometerM() != null) {
+ return BigDecimal.valueOf(event.odometerM());
+ }
+ BigDecimal meters = decimal(rawValue(event, "odometerM"));
+ if (meters != null) {
+ return meters;
+ }
+ BigDecimal kilometres = decimal(rawValue(event, "odometerKm"));
+ return kilometres == null ? null : kilometres.multiply(BigDecimal.valueOf(1000));
+ }
+
+ private String activitySlot(EventHubEventDto event) {
+ return firstNonBlank(
+ rawValue(event, "slot"),
+ rawValue(event, "cardSlot"),
+ detailAttribute(event, "cardSlot")
+ );
+ }
+
+ private String detailValue(EventHubEventDto event, String field) {
+ return firstNonBlank(rawValue(event, field), detailAttribute(event, field));
+ }
+
+ private String rawValue(EventHubEventDto event, String field) {
+ return text(RuntimeEntityReferenceResolver.rawPayload(event), field);
+ }
+
+ private String detailAttribute(EventHubEventDto event, String field) {
+ JsonNode attributes = event == null || event.eventDetails() == null
+ ? null
+ : event.eventDetails().attributes();
+ return text(attributes, field);
+ }
+
+ private boolean nationCompatible(String left, String right) {
+ String normalizedLeft = normalizedNation(left, null);
+ String normalizedRight = normalizedNation(right, null);
+ return optionalTokenCompatible(normalizedLeft, normalizedRight);
+ }
+
+ private String normalizedNation(String nation, Integer numericCode) {
+ TachographNationRegistry.NationResolution resolution =
+ TachographNationRegistry.resolve(nation, numericCode);
+ if (resolution.numericCode() != null) {
+ return String.valueOf(resolution.numericCode());
+ }
+ return normalizeToken(resolution.legacyNation());
+ }
+
+ private boolean regionCompatible(String left, String right) {
+ return optionalTokenCompatible(normalizedRegion(left), normalizedRegion(right));
+ }
+
+ private String normalizedRegion(String value) {
+ String normalized = normalizeToken(value);
+ return normalized == null || Objects.equals("0", normalized) ? null : normalized;
+ }
+
+ private boolean optionalTokenCompatible(String left, String right) {
+ String normalizedLeft = normalizeToken(left);
+ String normalizedRight = normalizeToken(right);
+ return normalizedLeft == null || normalizedRight == null || normalizedLeft.equals(normalizedRight);
+ }
+
+ private boolean optionalDecimalCompatible(BigDecimal left, BigDecimal right, BigDecimal tolerance) {
+ if (left == null || right == null) {
+ return true;
+ }
+ return left.subtract(right).abs().compareTo(tolerance) <= 0;
+ }
+
+ private BigDecimal decimal(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ try {
+ return new BigDecimal(value.trim());
+ } catch (NumberFormatException ignored) {
+ return null;
+ }
+ }
+
+ private String text(JsonNode node, String field) {
+ if (node == null || field == null) {
+ return null;
+ }
+ JsonNode value = node.get(field);
+ if (value == null || value.isNull()) {
+ return null;
+ }
+ String text = value.asText(null);
+ return text == null || text.isBlank() ? null : text.trim();
+ }
+
+ private String normalizeIdentifier(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ String normalized = value.trim().toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "");
+ return normalized.isBlank() ? null : normalized;
+ }
+
+ private String normalizeToken(String value) {
+ return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT);
+ }
+
+ private String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+
+ private String nullToEmpty(String value) {
+ return value == null ? "" : value;
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographVehicleIdentityFusionPolicy.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographVehicleIdentityFusionPolicy.java
new file mode 100644
index 0000000..093b7f8
--- /dev/null
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographVehicleIdentityFusionPolicy.java
@@ -0,0 +1,105 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import at.procon.eventhub.dto.EventHubEventDto;
+import at.procon.eventhub.dto.VehicleRefDto;
+import at.procon.eventhub.dto.VehicleRegistrationRefDto;
+import java.util.List;
+import java.util.Objects;
+import org.springframework.stereotype.Component;
+
+@Component
+public class RuntimeTachographVehicleIdentityFusionPolicy implements RuntimeEventFusionPolicy {
+
+ public static final String POLICY_ID = "TACHOGRAPH_ENRICH_PRIMARY_VEHICLE_IDENTITY";
+
+ @Override
+ public String policyId() {
+ return POLICY_ID;
+ }
+
+ @Override
+ public EventHubEventDto fuse(EventHubEventDto primary, List secondaries) {
+ if (primary == null || secondaries == null || secondaries.isEmpty()) {
+ return primary;
+ }
+ VehicleRefDto bestSecondary = secondaries.stream()
+ .map(EventHubEventDto::vehicleRef)
+ .filter(Objects::nonNull)
+ .filter(VehicleRefDto::hasAnyReference)
+ .filter(RuntimeTachographVehicleIdentityFusionPolicy::hasVehicleIdentity)
+ .findFirst()
+ .orElse(null);
+ if (bestSecondary == null || !shouldEnrichVehicleRef(primary.vehicleRef(), bestSecondary)) {
+ return primary;
+ }
+ VehicleRefDto merged = mergeVehicleRef(primary.vehicleRef(), bestSecondary);
+ if (Objects.equals(primary.vehicleRef(), merged)) {
+ return primary;
+ }
+ return new EventHubEventDto(
+ primary.eventId(),
+ primary.externalSourceEventId(),
+ primary.driverRef(),
+ merged,
+ primary.occurredAt(),
+ primary.receivedPartnerAt(),
+ primary.receivedHubAt(),
+ primary.eventDomain(),
+ primary.eventType(),
+ primary.lifecycle(),
+ primary.odometerM(),
+ primary.position(),
+ primary.eventDetails(),
+ primary.sourcePackageRef(),
+ primary.payload(),
+ primary.manualEntry(),
+ primary.packageInfo()
+ );
+ }
+
+ private static boolean shouldEnrichVehicleRef(VehicleRefDto primary, VehicleRefDto secondary) {
+ return secondary != null
+ && hasVehicleIdentity(secondary)
+ && (primary == null || !hasVehicleIdentity(primary));
+ }
+
+ private static boolean hasVehicleIdentity(VehicleRefDto vehicleRef) {
+ return vehicleRef != null
+ && (notBlank(vehicleRef.sourceVehicleEntityId()) || notBlank(vehicleRef.vin()));
+ }
+
+ private static VehicleRefDto mergeVehicleRef(VehicleRefDto primary, VehicleRefDto secondary) {
+ if (primary == null) {
+ return secondary;
+ }
+ if (secondary == null) {
+ return primary;
+ }
+ VehicleRegistrationRefDto registration = primary.vehicleRegistration() != null
+ && primary.vehicleRegistration().hasValue()
+ ? primary.vehicleRegistration()
+ : secondary.vehicleRegistration();
+ return new VehicleRefDto(
+ firstNonBlank(primary.sourceVehicleEntityId(), secondary.sourceVehicleEntityId()),
+ firstNonBlank(primary.vin(), secondary.vin()),
+ firstNonBlank(primary.sourceRegistrationEntityId(), secondary.sourceRegistrationEntityId()),
+ registration
+ );
+ }
+
+ private static boolean notBlank(String value) {
+ return value != null && !value.isBlank();
+ }
+
+ private static String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/EventEvidenceMixingModule.java b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/EventEvidenceMixingModule.java
index 712ab6c..0f46cc2 100644
--- a/src/main/java/at/procon/eventhub/processing/eventprocessing/module/EventEvidenceMixingModule.java
+++ b/src/main/java/at/procon/eventhub/processing/eventprocessing/module/EventEvidenceMixingModule.java
@@ -39,7 +39,7 @@ public class EventEvidenceMixingModule implements RuntimeProcessingModule {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Event evidence mixing",
- "Applies source-aware runtime evidence rules before intervalization. The rule registry collapses duplicate tachograph card/VU evidence and duplicate file-session/database representations while keeping CARD_VEHICLES_USED/IW_CYCLE unchanged for vehicle-usage processing.",
+ "Applies source-aware runtime evidence rules before intervalization. Generic selectors, pair constraints, compatibility policies and fusion policies are supplied by source-specific rule providers.",
"JAVA",
Set.of(DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY),
Set.of("UnifiedRuntimeEventBundle"),
@@ -64,13 +64,9 @@ public class EventEvidenceMixingModule implements RuntimeProcessingModule {
metadata.put("resolvedEventCount", mixed.resolvedEvents().size());
metadata.put("eventMixingDecisionCount", mixed.eventMixingDecisions().size());
metadata.put("eventMixingMode", eventMixingMode(context));
- metadata.put("tachographEventCount", mixed.diagnostics().tachographEventCount());
- metadata.put("driverCardSourceRoleCount", mixed.diagnostics().driverCardSourceRoleCount());
- metadata.put("vehicleUnitSourceRoleCount", mixed.diagnostics().vehicleUnitSourceRoleCount());
- metadata.put("unknownSourceRoleCount", mixed.diagnostics().unknownSourceRoleCount());
- metadata.put("databaseRepresentationCount", mixed.diagnostics().databaseRepresentationCount());
- metadata.put("fileSessionRepresentationCount", mixed.diagnostics().fileSessionRepresentationCount());
- metadata.put("unknownRepresentationCount", mixed.diagnostics().unknownRepresentationCount());
+ metadata.put("sourceFamilyCounts", mixed.diagnostics().sourceFamilyCounts());
+ metadata.put("sourceRoleCounts", mixed.diagnostics().sourceRoleCounts());
+ metadata.put("representationCounts", mixed.diagnostics().representationCounts());
metadata.put("candidateGroupCount", mixed.diagnostics().candidateGroupCount());
metadata.put("compatibilityRejectedCount", mixed.diagnostics().compatibilityRejectedCount());
return new RuntimeProcessingModuleResult(
@@ -110,6 +106,6 @@ public class EventEvidenceMixingModule implements RuntimeProcessingModule {
if (value == null) {
value = context.attributes().get(EVENT_MIXING_MODE_PARAMETER);
}
- return value == null ? RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE : value.toString();
+ return value == null ? RuntimeEventMixingService.MODE_FULL : value.toString();
}
}
diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingGeneralityTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingGeneralityTest.java
new file mode 100644
index 0000000..359fb6a
--- /dev/null
+++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingGeneralityTest.java
@@ -0,0 +1,80 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.RecordComponent;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+class RuntimeEventMixingGeneralityTest {
+
+ @Test
+ void commonRuleDescriptorAndProfileDoNotExposeTachographSpecificTypes() {
+ assertNoTachographRecordComponent(RuntimeEventMixingRule.class);
+ assertNoTachographRecordComponent(RuntimeEventDescriptor.class);
+ assertNoTachographRecordComponent(RuntimeEventSourceProfile.class);
+ }
+
+ @Test
+ void selectorsAndPairConstraintsWorkWithArbitraryClassificationValues() {
+ RuntimeEventSourceProfile primaryProfile = new RuntimeEventSourceProfile(
+ "CUSTOM_SYSTEM",
+ "SENSOR",
+ "CUSTOM_SAMPLE",
+ Map.of(
+ RuntimeEventClassificationKeys.SOURCE_FAMILY, "CUSTOM_FAMILY",
+ RuntimeEventClassificationKeys.SOURCE_ROLE, "AUTHORITATIVE",
+ RuntimeEventClassificationKeys.REPRESENTATION, "DATABASE"
+ )
+ );
+ RuntimeEventSourceProfile secondaryProfile = new RuntimeEventSourceProfile(
+ "CUSTOM_SYSTEM",
+ "SENSOR",
+ "CUSTOM_SAMPLE",
+ Map.of(
+ RuntimeEventClassificationKeys.SOURCE_FAMILY, "CUSTOM_FAMILY",
+ RuntimeEventClassificationKeys.SOURCE_ROLE, "AUTHORITATIVE",
+ RuntimeEventClassificationKeys.REPRESENTATION, "FILE"
+ )
+ );
+ RuntimeEventDescriptor primary = descriptor(primaryProfile);
+ RuntimeEventDescriptor secondary = descriptor(secondaryProfile);
+
+ RuntimeEventSelector databaseSelector = RuntimeEventSelector.of(Map.of(
+ RuntimeEventClassificationKeys.SOURCE_FAMILY, Set.of("CUSTOM_FAMILY"),
+ RuntimeEventClassificationKeys.REPRESENTATION, Set.of("DATABASE")
+ ));
+ RuntimeEventPairConstraint sameRole = new RuntimeEventPairConstraint(
+ Set.of(RuntimeEventClassificationKeys.SOURCE_ROLE)
+ );
+
+ assertThat(databaseSelector.matches(primary)).isTrue();
+ assertThat(databaseSelector.matches(secondary)).isFalse();
+ assertThat(sameRole.matches(primary, secondary)).isTrue();
+ }
+
+ private RuntimeEventDescriptor descriptor(RuntimeEventSourceProfile profile) {
+ return new RuntimeEventDescriptor(
+ null,
+ "identity",
+ "event-key",
+ profile,
+ "activity-key",
+ "support-key",
+ false,
+ false,
+ true
+ );
+ }
+
+ private void assertNoTachographRecordComponent(Class> type) {
+ assertThat(type.isRecord()).isTrue();
+ assertThat(Arrays.stream(type.getRecordComponents())
+ .map(RecordComponent::getType)
+ .map(Class::getName)
+ .noneMatch(name -> name.contains("Tachograph")))
+ .isTrue();
+ }
+}
diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingServiceTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingServiceTest.java
index 2649ec7..a9a2bc6 100644
--- a/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingServiceTest.java
+++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeEventMixingServiceTest.java
@@ -25,14 +25,14 @@ import org.junit.jupiter.api.Test;
class RuntimeEventMixingServiceTest {
- private final RuntimeEventMixingService service = new RuntimeEventMixingService();
+ private final RuntimeEventMixingService service = RuntimeTachographMixingTestFactory.mixingService();
@Test
void suppressesVuActivityDuplicateFromActivityTimelineWhenCardActivityHasSameEventKey() {
EventHubEventDto card = activity("CARD_ACTIVITY", "DRIVER_CARD", "TACHOGRAPH:CARD_ACTIVITY:1:START");
EventHubEventDto vu = activity("VU_ACTIVITY", "VEHICLE_UNIT", "TACHOGRAPH:VU_ACTIVITY:2:START");
- RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_FULL);
assertThat(mixed.rawEvents()).hasSize(2);
assertThat(mixed.activityTimelineEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -43,14 +43,14 @@ class RuntimeEventMixingServiceTest {
.containsExactly("TACHOGRAPH:VU_ACTIVITY:2:START");
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
- .isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_SAME_EVENT_KEY);
+ .isEqualTo(RuntimeTachographEventMixingRuleProvider.RULE_CARD_VU_ACTIVITY_SAME_EVENT_KEY);
}
@Test
void keepsVuActivityWhenNoMatchingCardActivityExists() {
EventHubEventDto vu = activity("VU_ACTIVITY", "VEHICLE_UNIT", "TACHOGRAPH:VU_ACTIVITY:2:START");
- RuntimeMixedEventBundle mixed = service.mix(List.of(vu), RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ RuntimeMixedEventBundle mixed = service.mix(List.of(vu), RuntimeEventMixingService.MODE_FULL);
assertThat(mixed.activityTimelineEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("TACHOGRAPH:VU_ACTIVITY:2:START");
@@ -69,7 +69,7 @@ class RuntimeEventMixingServiceTest {
"TACHOGRAPH_FILE_SESSION:22222222-2222-2222-2222-222222222222:ACTIVITY:vu-interval-1:START:2026-04-01T00:00:00Z"
);
- RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_FULL);
assertThat(mixed.activityTimelineEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:ACTIVITY:card-interval-1:START:2026-04-01T00:00:00Z");
@@ -97,7 +97,7 @@ class RuntimeEventMixingServiceTest {
EventLifecycle.INSERT
);
- RuntimeMixedEventBundle mixed = service.mix(List.of(cardVehicleUsed, iwCycle), RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ RuntimeMixedEventBundle mixed = service.mix(List.of(cardVehicleUsed, iwCycle), RuntimeEventMixingService.MODE_FULL);
assertThat(mixed.vehicleUsageEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("TACHOGRAPH:CARD_VEHICLES_USED:10:INSERT", "TACHOGRAPH:IW_CYCLE:20:INSERT");
@@ -126,7 +126,7 @@ class RuntimeEventMixingServiceTest {
true
);
- RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_FULL);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("TACHOGRAPH:CARD_POSITION:1");
@@ -136,7 +136,7 @@ class RuntimeEventMixingServiceTest {
.containsExactly("TACHOGRAPH:VU_POSITION:2");
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
- .isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY);
+ .isEqualTo(RuntimeTachographEventMixingRuleProvider.RULE_CARD_VU_SUPPORT_COMPATIBLE_KEY);
assertThat(mixed.eventMixingDecisions().getFirst().channel())
.isEqualTo("SUPPORT_EVIDENCE");
}
@@ -180,7 +180,7 @@ class RuntimeEventMixingServiceTest {
true
);
- RuntimeMixedEventBundle mixed = service.mix(List.of(cardPlace, vuPlace, cardBorder, vuBorder), RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ RuntimeMixedEventBundle mixed = service.mix(List.of(cardPlace, vuPlace, cardBorder, vuBorder), RuntimeEventMixingService.MODE_FULL);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("TACHOGRAPH:CARD_BORDER_CROSSING:3", "TACHOGRAPH:CARD_PLACE:1");
@@ -210,7 +210,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(cardPlace, vuPlace),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -243,7 +243,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(cardPlace, vuPlace),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -252,7 +252,7 @@ class RuntimeEventMixingServiceTest {
.containsExactly("TACHOGRAPH:VU_PLACE:BEGIN");
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
- .isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY);
+ .isEqualTo(RuntimeTachographEventMixingRuleProvider.RULE_CARD_VU_SUPPORT_COMPATIBLE_KEY);
}
@Test
@@ -274,7 +274,7 @@ class RuntimeEventMixingServiceTest {
true
);
- RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ RuntimeMixedEventBundle mixed = service.mix(List.of(card, vu), RuntimeEventMixingService.MODE_FULL);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
.containsExactly("TACHOGRAPH_FILE_SESSION:11111111-1111-1111-1111-111111111111:SUPPORT:card-position-1:SNAPSHOT:2026-04-01T00:00:00Z");
@@ -338,7 +338,7 @@ class RuntimeEventMixingServiceTest {
cardOut, vuOut,
cardFerryTrain, vuFerryTrain
),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -405,7 +405,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(cardLoad, vuLoad, cardOut, vuOut),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -448,7 +448,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseVu),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.supportEvidenceEvents()).hasSize(1);
@@ -460,7 +460,7 @@ class RuntimeEventMixingServiceTest {
.containsExactly(databaseVu.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
- .isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_SUPPORT_COMPATIBLE_KEY);
+ .isEqualTo(RuntimeTachographEventMixingRuleProvider.RULE_CARD_VU_SUPPORT_COMPATIBLE_KEY);
}
@Test
@@ -479,7 +479,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseVu),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.activityTimelineEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -488,7 +488,7 @@ class RuntimeEventMixingServiceTest {
.containsExactly(databaseVu.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
- .isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_CARD_VU_ACTIVITY_COMPATIBLE_KEY);
+ .isEqualTo(RuntimeTachographEventMixingRuleProvider.RULE_CARD_VU_ACTIVITY_COMPATIBLE_KEY);
}
@Test
@@ -517,7 +517,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileVu, databaseVu),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -526,10 +526,10 @@ class RuntimeEventMixingServiceTest {
.containsExactly(fileVu.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
- .isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_SUPPORT_SAME_ROLE);
- assertThat(mixed.diagnostics().vehicleUnitSourceRoleCount()).isEqualTo(2);
- assertThat(mixed.diagnostics().databaseRepresentationCount()).isEqualTo(1);
- assertThat(mixed.diagnostics().fileSessionRepresentationCount()).isEqualTo(1);
+ .isEqualTo(RuntimeTachographEventMixingRuleProvider.RULE_DB_FILE_SESSION_SUPPORT_SAME_ROLE);
+ assertThat(mixed.diagnostics().sourceRoleCounts()).containsEntry("VEHICLE_UNIT", 2);
+ assertThat(mixed.diagnostics().representationCounts()).containsEntry("DATABASE", 1);
+ assertThat(mixed.diagnostics().representationCounts()).containsEntry("FILE_SESSION", 1);
}
@Test
@@ -550,7 +550,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseCard),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.activityTimelineEvents()).extracting(EventHubEventDto::externalSourceEventId)
@@ -559,7 +559,7 @@ class RuntimeEventMixingServiceTest {
.containsExactly(fileCard.externalSourceEventId());
assertThat(mixed.eventMixingDecisions()).hasSize(1);
assertThat(mixed.eventMixingDecisions().getFirst().ruleId())
- .isEqualTo(RuntimeEventMixingService.RULE_TACHOGRAPH_DB_FILE_SESSION_ACTIVITY_SAME_ROLE);
+ .isEqualTo(RuntimeTachographEventMixingRuleProvider.RULE_DB_FILE_SESSION_ACTIVITY_SAME_ROLE);
}
@Test
@@ -586,7 +586,7 @@ class RuntimeEventMixingServiceTest {
RuntimeMixedEventBundle mixed = service.mix(
List.of(fileCard, databaseVu),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(mixed.supportEvidenceEvents()).extracting(EventHubEventDto::externalSourceEventId)
diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographMixingTestFactory.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographMixingTestFactory.java
new file mode 100644
index 0000000..c3492a3
--- /dev/null
+++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographMixingTestFactory.java
@@ -0,0 +1,33 @@
+package at.procon.eventhub.processing.eventprocessing.mixing;
+
+import java.util.List;
+
+final class RuntimeTachographMixingTestFactory {
+
+ private RuntimeTachographMixingTestFactory() {
+ }
+
+ static RuntimeEventDescriptorFactory descriptorFactory() {
+ return new RuntimeEventDescriptorFactory(List.of(new RuntimeTachographEventSemantics()));
+ }
+
+ static RuntimeEventMixingService mixingService() {
+ RuntimeTachographEvidenceCompatibilityPolicy supportPolicy =
+ new RuntimeTachographEvidenceCompatibilityPolicy();
+ RuntimeEventCompatibilityPolicyRegistry compatibilityPolicies =
+ new RuntimeEventCompatibilityPolicyRegistry(List.of(
+ supportPolicy,
+ new RuntimeTachographActivityCompatibilityPolicy(supportPolicy)
+ ));
+ return new RuntimeEventMixingService(
+ descriptorFactory(),
+ new RuntimeEventMixingRuleRegistry(List.of(
+ new RuntimeTachographEventMixingRuleProvider()
+ )),
+ new RuntimeEventEvidenceCompatibilityMatcher(compatibilityPolicies),
+ new RuntimeEventFusionPolicyRegistry(List.of(
+ new RuntimeTachographVehicleIdentityFusionPolicy()
+ ))
+ );
+ }
+}
diff --git a/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographRepresentationParityTest.java b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographRepresentationParityTest.java
index adb1620..3407ba7 100644
--- a/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographRepresentationParityTest.java
+++ b/src/test/java/at/procon/eventhub/processing/eventprocessing/mixing/RuntimeTachographRepresentationParityTest.java
@@ -25,8 +25,8 @@ class RuntimeTachographRepresentationParityTest {
private static final OffsetDateTime OCCURRED_AT = OffsetDateTime.parse("2026-04-01T08:00:00Z");
- private final RuntimeEventDescriptorFactory descriptorFactory = new RuntimeEventDescriptorFactory();
- private final RuntimeEventMixingService mixingService = new RuntimeEventMixingService();
+ private final RuntimeEventDescriptorFactory descriptorFactory = RuntimeTachographMixingTestFactory.descriptorFactory();
+ private final RuntimeEventMixingService mixingService = RuntimeTachographMixingTestFactory.mixingService();
@Test
void producesSameCanonicalSourceProfileForDbAndFileSessionPlaceRepresentations() {
@@ -58,14 +58,14 @@ class RuntimeTachographRepresentationParityTest {
assertThat(fileDescriptor.sourceProfile().sourceKind()).isEqualTo("DRIVER_CARD");
assertThat(dbDescriptor.sourceProfile().extractionCode()).isEqualTo("CARD_PLACE");
assertThat(fileDescriptor.sourceProfile().extractionCode()).isEqualTo("CARD_PLACE");
- assertThat(dbDescriptor.evidenceSourceRole())
- .isEqualTo(RuntimeTachographEvidenceSourceRole.DRIVER_CARD);
- assertThat(fileDescriptor.evidenceSourceRole())
- .isEqualTo(RuntimeTachographEvidenceSourceRole.DRIVER_CARD);
- assertThat(dbDescriptor.representation())
- .isEqualTo(RuntimeTachographRepresentation.DATABASE);
- assertThat(fileDescriptor.representation())
- .isEqualTo(RuntimeTachographRepresentation.FILE_SESSION);
+ assertThat(dbDescriptor.classification(RuntimeEventClassificationKeys.SOURCE_ROLE))
+ .isEqualTo("DRIVER_CARD");
+ assertThat(fileDescriptor.classification(RuntimeEventClassificationKeys.SOURCE_ROLE))
+ .isEqualTo("DRIVER_CARD");
+ assertThat(dbDescriptor.classification(RuntimeEventClassificationKeys.REPRESENTATION))
+ .isEqualTo("DATABASE");
+ assertThat(fileDescriptor.classification(RuntimeEventClassificationKeys.REPRESENTATION))
+ .isEqualTo("FILE_SESSION");
assertThat(fileDescriptor.compatibleSupportEvidenceKey())
.isEqualTo(dbDescriptor.compatibleSupportEvidenceKey());
}
@@ -116,14 +116,18 @@ class RuntimeTachographRepresentationParityTest {
assertThat(fileCvuProfile.sourceKind()).isEqualTo(dbCvuProfile.sourceKind());
assertThat(fileCvuProfile.extractionCode()).isEqualTo(dbCvuProfile.extractionCode());
- assertThat(fileCvuProfile.evidenceSourceRole()).isEqualTo(dbCvuProfile.evidenceSourceRole());
+ assertThat(fileCvuProfile.classification(RuntimeEventClassificationKeys.SOURCE_ROLE))
+ .isEqualTo(dbCvuProfile.classification(RuntimeEventClassificationKeys.SOURCE_ROLE));
assertThat(fileIwProfile.sourceKind()).isEqualTo(dbIwProfile.sourceKind());
assertThat(fileIwProfile.extractionCode()).isEqualTo(dbIwProfile.extractionCode());
- assertThat(fileIwProfile.evidenceSourceRole()).isEqualTo(dbIwProfile.evidenceSourceRole());
+ assertThat(fileIwProfile.classification(RuntimeEventClassificationKeys.SOURCE_ROLE))
+ .isEqualTo(dbIwProfile.classification(RuntimeEventClassificationKeys.SOURCE_ROLE));
assertThat(dbCvuProfile.extractionCode()).isEqualTo("CARD_VEHICLES_USED");
assertThat(dbIwProfile.extractionCode()).isEqualTo("IW_CYCLE");
- assertThat(fileCvuProfile.representation()).isEqualTo(RuntimeTachographRepresentation.FILE_SESSION);
- assertThat(dbCvuProfile.representation()).isEqualTo(RuntimeTachographRepresentation.DATABASE);
+ assertThat(fileCvuProfile.classification(RuntimeEventClassificationKeys.REPRESENTATION))
+ .isEqualTo("FILE_SESSION");
+ assertThat(dbCvuProfile.classification(RuntimeEventClassificationKeys.REPRESENTATION))
+ .isEqualTo("DATABASE");
}
@Test
@@ -149,7 +153,7 @@ class RuntimeTachographRepresentationParityTest {
EventLifecycle.START
)
),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
RuntimeMixedEventBundle fileMixed = mixingService.mix(
List.of(
@@ -172,7 +176,7 @@ class RuntimeTachographRepresentationParityTest {
EventLifecycle.BEGIN
)
),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(fileMixed.supportEvidenceEvents()).hasSameSizeAs(dbMixed.supportEvidenceEvents());
@@ -199,7 +203,7 @@ class RuntimeTachographRepresentationParityTest {
"TACHOGRAPH:VU_ACTIVITY:20:START",
EventDomain.DRIVER_ACTIVITY, EventType.DRIVE, EventLifecycle.START)
),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
RuntimeMixedEventBundle fileMixed = mixingService.mix(
List.of(
@@ -210,7 +214,7 @@ class RuntimeTachographRepresentationParityTest {
"TACHOGRAPH_FILE_SESSION:22222222-2222-2222-2222-222222222222:ACTIVITY:vu-20:START:2026-04-01T08:00:00Z",
EventDomain.DRIVER_ACTIVITY, EventType.DRIVE, EventLifecycle.START)
),
- RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE
+ RuntimeEventMixingService.MODE_FULL
);
assertThat(fileMixed.activityTimelineEvents()).hasSameSizeAs(dbMixed.activityTimelineEvents());
@@ -254,9 +258,9 @@ class RuntimeTachographRepresentationParityTest {
);
RuntimeMixedEventBundle dbMixed = mixingService.mix(
- dbEvents, RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ dbEvents, RuntimeEventMixingService.MODE_FULL);
RuntimeMixedEventBundle fileMixed = mixingService.mix(
- fileEvents, RuntimeEventMixingService.MODE_TACHOGRAPH_SAME_SOURCE);
+ fileEvents, RuntimeEventMixingService.MODE_FULL);
assertThat(dbMixed.vehicleUsageEvents()).hasSize(4);
assertThat(fileMixed.vehicleUsageEvents()).hasSize(4);