Compare commits

...

2 Commits

Author SHA1 Message Date
trifonovt 34e6c6f236 Reconcile CVU intervals across representations 2026-06-16 17:04:45 +02:00
trifonovt 1dd6d127a4 Refactor runtime event mixing into pluggable policies 2026-06-16 12:26:35 +02:00
44 changed files with 2919 additions and 884 deletions

View File

@ -0,0 +1,39 @@
# CVU boundary-censoring reconciliation fix
## Problem
The previous cross-representation CVU patch reduced the mixed result from 22 to 12 vehicle-usage intervals, but one duplicate pair remained.
The unmatched period was represented as:
- file session: `2026-03-31T00:00:00Z` to `2026-04-02T12:27:15Z`, missing begin odometer;
- database: `2026-04-01T00:00:00Z` to `2026-04-02T12:27:15Z`, complete database vehicle identity.
The file interval starts at the file-session load boundary and has no observed begin odometer. The database representation starts at the requested boundary. Before final projection clipping, their starts differ by 24 hours, so the prior 60-second boundary rule rejected them. After clipping, both appeared as the same interval in the REST response.
## Fix
Cross-representation CVU matching now accepts a one-sided boundary-censored match when:
- driver and vehicle identity are compatible;
- the intervals overlap;
- the opposite boundary matches within 60 seconds; and
- the non-aligned boundary has a missing corresponding odometer in at least one representation.
Known, conflicting boundaries are still kept separate.
Fusion keeps the database interval identity and vehicle/VIN data, but preserves the wider temporal boundary. It does not copy an odometer value onto an earlier/later boundary where that value was not actually observed.
New decision types:
- `LEFT_BOUNDARY_CENSORED`
- `RIGHT_BOUNDARY_CENSORED`
## Regression coverage
Added tests for:
- the supplied March 31 / April 1 left-boundary case;
- preserving a `null` begin odometer on the widened interval;
- retaining database identity and file-session provenance;
- not reconciling intervals whose differing starts are both explicitly observed.

View File

@ -0,0 +1,30 @@
# CVU final merge safeguard
## Problem
After cross-representation CVU reconciliation the supplied mixed execution still contained 12 effective vehicle-usage intervals instead of 11. The remaining pair represented the same usage period:
- file-session CVU: `2026-03-31T00:00:00Z` to `2026-04-02T12:27:15Z`, registration present, VIN missing;
- database CVU: `2026-04-01T00:00:00Z` to `2026-04-02T12:27:15Z`, same registration, VIN present.
The final `DriverVehicleUsageMergeModule` required exact `vehicleKey` equality, so `null` and the database VIN prevented coalescing even though registration and overlap proved compatible identity.
## Fix
`DriverVehicleUsageMergeModule` now:
- accepts complementary vehicle identity when registration or VIN matches and the other field is missing;
- merges overlapping as well as immediately adjacent effective intervals;
- rejects explicit registration/VIN conflicts;
- rejects materially conflicting odometers at identical boundaries;
- selects the richer interval as identity primary;
- preserves the earliest start, latest end, correct boundary odometers, and complete source provenance;
- reports `coalescedIntervalCount` metadata.
This is a generic final-interval safeguard and does not depend on tachograph representation enums.
## Expected supplied-data result
- vehicle usage intervals: `12 -> 11`
- VU card-absent intervals: remains `10`
- support geo events: remains `141`

71
README_CVU_PATCH.md Normal file
View File

@ -0,0 +1,71 @@
# Patch: Cross-representation CVU interval reconciliation
## Problem
A request containing both `TACHOGRAPH_FILE_SESSION` and `TACHOGRAPH_DB` produced 22 final vehicle-usage intervals instead of 11.
The old sequence globally sorted all `CARD_VEHICLES_USED` intervals before technical-midnight normalization. A complete database interval could therefore appear between two file-session fragments:
```text
file fragment ending 23:59:59
complete database interval
file continuation starting 00:00:00
```
The database interval interrupted adjacency, so the file fragments were not coalesced. The remaining file fragment and complete database interval both survived.
## Fix
The reconciliation order is now:
1. Classify reconstructed intervals by source type and physical representation.
2. Normalize CVU technical-midnight fragments independently for `DATABASE`, `FILE_SESSION`, and `UNKNOWN` representations.
3. Reconcile equivalent database and file-session CVU intervals.
- exact/compatible boundaries use the existing 60-second tolerance;
- driver and vehicle identity must be compatible;
- non-null odometer boundaries may differ by at most 1 km;
- the database interval is retained as primary;
- file-session source IDs are attached as provenance.
4. Reconcile the representation-neutral effective CVU intervals with `IW_CYCLE`.
5. Continue with the existing final vehicle-usage merge.
## Diagnostics
`RuntimeVehicleUsageReconciliationResult` now exposes:
```text
representationReconciledCardVehicleUsedIntervals
```
The module metadata includes:
```text
representationReconciledCardVehicleUsedIntervalCount
```
A new decision rule identifies suppressed cross-representation duplicates:
```text
tachograph.vehicle-usage.card-vehicles-used.cross-representation-equivalent
```
## Regression coverage
Tests cover:
- the production ordering where a DB interval interrupts two file fragments;
- DB-primary fusion with file-session provenance;
- preservation of intervals when odometer boundaries meaningfully conflict;
- existing CVU midnight normalization and CVU/IW reconciliation behavior.
A Java 21 harness using all 11 periods from the supplied result set produced:
```text
raw intervals: 51
representation-normalized CVU: 22
representation-reconciled CVU: 11
final effective vehicle usage: 11
suppressed cross-representation CVU: 11
```
Maven was not available in the execution environment, so the complete project suite was not run. The changed main classes and regression test were compiled with Java 21 dependency stubs, and the interval-level harness executed successfully.

View File

@ -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.

View File

@ -0,0 +1,186 @@
# Fahrer Home / NotHome Klassifikation + Trip-Segmentierung — Business Rules
```
# ════════════════════════════════════════════════════════════
# Quelle: Tachograph M_ (Vehicle Unit, GNSS-Positionen)
# + C_ (Fahrerkarte, Aktivitäten / Steckung+Entnahme)
# ════════════════════════════════════════════════════════════
# ───────────────────── Konstanten ─────────────────────
CONST NDI_LONG = 7.5h # Schwelle "langes" NDI
CONST NDI_VERY_LONG = 24h
CONST CARD_REMOVAL_PCT = 0.80 # 80% der NDI-Dauer
CONST VISIT_SHARE = 0.25 # 25% aller (NDI > 7.5h)
CONST DBSCAN_EPS = 150m # geo-Metrik (Haversine / PostGIS)
CONST DBSCAN_MIN_PTS = 3
ENUM HomeStatus { HOME, NOT_HOME }
# ───────────────────── Datenmodell ─────────────────────
STRUCT DI: # Lenkintervall
driverId
vehicleId
start; end
posStart; posEnd # (x,y) aus M_, nullable
gnssTrace[] # (ts, x, y) Stützpunkte aus M_
STRUCT NDI: # Nicht-Lenkintervall (Ruhe)
driverId
vehicleStart; vehicleEnd # Fahrzeug der vorigen / nächsten DI
start; end # = vorige.end / nächste.start
pos = null # zugeordnete (x,y), nullable
cardOut = null # Intervall [Entnahme, Steckung], nullable
cluster = null # DBSCAN-Cluster-Id (inkl. NOISE)
status = null # HOME | NOT_HOME
FUNCTION dur(iv) = iv.end - iv.start
# ═══════════ 1. NDI aus aufeinanderfolgenden DI ableiten ═══════════
FUNCTION buildNDIs(driverId, dis): # dis chronologisch sortiert
out = []
FOR i IN 1 .. len(dis)-1:
prev = dis[i-1]; next = dis[i]
out += NDI {
driverId : driverId
vehicleStart : prev.vehicleId
vehicleEnd : next.vehicleId
start : prev.end
end : next.start
pos : assignPos(prev, next)
cardOut : cardRemovalInterval(prev.end, next.start) # aus C_ Events
}
RETURN out
# Fahrzeug steht während NDI -> letzte bekannte Position zuordnen
FUNCTION assignPos(prev, next):
RETURN prev.posEnd ?? next.posStart # nullable
# ═══════════ 2. LocationStatistik + DBSCAN ═══════════
# nur NDI > 7.5h mit bekannter Position fließen in die Statistik
FUNCTION clusterLongNDIs(allNDIs):
longs = [n IN allNDIs WHERE dur(n) > NDI_LONG AND n.pos != null]
labels = DBSCAN(points = [n.pos FOR n IN longs],
eps = DBSCAN_EPS, minPts = DBSCAN_MIN_PTS, metric = HAVERSINE)
FOR n, lbl IN zip(longs, labels):
n.cluster = lbl
RETURN longs # inkl. NOISE-Label
# ═══════════ 3. Home-Locations bestimmen ═══════════
FUNCTION determineHomeLocations(longs):
# 3a Firmen-Home (Depots): Cluster mit > 25% ALLER langen NDI
totalCompany = count(longs) # Nenner = alle langen NDI
companyHome = { c FOR (c, visits) IN groupByCluster(longs)
WHERE c != NOISE
AND count(visits) / totalCompany > VISIT_SHARE }
# 3b Fahrer-Home (privat): pro Fahrer Cluster mit > 25% SEINER langen NDI,
# außerhalb der Schnittmenge mit den Firmen-Clustern
driverHome = {} # driverId -> Set<cluster>
FOR (driverId, dnNDIs) IN groupByDriver(longs):
totalDriver = count(dnNDIs)
FOR (c, visits) IN groupByCluster(dnNDIs):
IF c != NOISE
AND count(visits) / totalDriver > VISIT_SHARE
AND c NOT IN companyHome: # Schnittmenge mit Firma entfernen
driverHome[driverId] += c
RETURN (companyHome, driverHome)
# ═══════════ 4. Klassifikation Home / NotHome ═══════════
FUNCTION classify(n, companyHome, driverHome):
# A Karte am NDI-Ende in anderem Fahrzeug gesteckt
IF n.vehicleStart != n.vehicleEnd: RETURN HOME
# B Karte > 80% des NDI entnommen
IF n.cardOut != null
AND dur(n.cardOut) > CARD_REMOVAL_PCT * dur(n): RETURN HOME
# C Ruhe > 24h
IF dur(n) > NDI_VERY_LONG: RETURN HOME
# D keine Position bekannt
IF n.pos == null:
RETURN (dur(n) > NDI_LONG) ? HOME : NOT_HOME
# E Position bekannt + langes NDI -> Entscheidung über Home-Location-Cluster
IF dur(n) > NDI_LONG:
IF n.cluster IN companyHome
OR n.cluster IN driverHome[n.driverId]:
RETURN HOME # Depot / privates Zuhause
ELSE:
RETURN NOT_HOME # Nächtigung im Fahrzeug
# kurze Ruhe beim Fahrzeug
RETURN NOT_HOME
# ═══════════ 5. Grenzübertritte -> TripSegmente ═══════════
STRUCT TripSegment:
driverId; vehicleId
start; end
countryFrom; countryTo
posFrom; posTo
# BorderCrossing = explizites Tacho-Event (Smart Tacho v2)
# ODER Länderwechsel im reverse-geocodeten GNSS-Trace
FUNCTION buildTripSegments(driverId, dis): # chronologisch
segs = []
segStart = dis[0].start
posFrom = dis[0].posStart
country = countryOf(dis[0].posStart) # PostGIS / Nominatim
FOR di IN dis:
FOR p IN di.gnssTrace: # (ts, x, y) aus M_
c = countryOf(p)
IF c != country: # -> Grenzübertritt
segs += TripSegment {
driverId : driverId
vehicleId : di.vehicleId
start : segStart
end : p.ts
countryFrom : country
countryTo : c
posFrom : posFrom
posTo : p
}
segStart = p.ts; posFrom = p; country = c
# Schluss-Segment (letzter Abschnitt ohne weiteren Übertritt)
segs += TripSegment {
driverId : driverId
vehicleId : dis[last].vehicleId
start : segStart
end : dis[last].end
countryFrom : country
countryTo : country
posFrom : posFrom
posTo : dis[last].posEnd
}
RETURN segs
# ═══════════ Orchestrierung ═══════════
FUNCTION run(files_M, files_C):
acts = parseTacho(files_M, files_C) # DI + Karten-Events
allNDIs = []
segments = []
FOR (driverId, dis) IN groupDIsByDriver(acts):
dis = sortByTime(dis)
allNDIs += buildNDIs(driverId, dis)
segments += buildTripSegments(driverId, dis)
# zwei Pässe: erst clustern, dann klassifizieren
longs = clusterLongNDIs(allNDIs)
(companyHome, driverHome) = determineHomeLocations(longs)
FOR n IN allNDIs:
n.status = classify(n, companyHome, driverHome)
RETURN (allNDIs, segments)
```

View File

@ -0,0 +1,187 @@
# Driver Home / NotHome Classification + Trip Segmentation — Business Rules
```text
# ════════════════════════════════════════════════════════════
# Source: Tachograph M_ (Vehicle Unit, GNSS positions)
# + C_ (Driver Card, activities / insertion + removal)
# ════════════════════════════════════════════════════════════
# ───────────────────── Constants ─────────────────────
CONST NDI_LONG = 7.5h # Threshold for a "long" NDI
CONST NDI_VERY_LONG = 24h
CONST CARD_REMOVAL_PCT = 0.80 # 80% of the NDI duration
CONST VISIT_SHARE = 0.25 # 25% of all (NDI > 7.5h)
CONST DBSCAN_EPS = 150m # Geographic metric (Haversine / PostGIS)
CONST DBSCAN_MIN_PTS = 3
ENUM HomeStatus { HOME, NOT_HOME }
# ───────────────────── Data Model ─────────────────────
STRUCT DI: # Driving interval
driverId
vehicleId
start; end
posStart; posEnd # (x,y) from M_, nullable
gnssTrace[] # (ts, x, y) supporting points from M_
STRUCT NDI: # Non-driving interval (rest)
driverId
vehicleStart; vehicleEnd # Vehicle of the previous / next DI
start; end # = previous.end / next.start
pos = null # Assigned (x,y), nullable
cardOut = null # Interval [removal, insertion], nullable
cluster = null # DBSCAN cluster ID (including NOISE)
status = null # HOME | NOT_HOME
FUNCTION dur(iv) = iv.end - iv.start
# ═══════════ 1. Derive NDIs from consecutive DIs ═══════════
FUNCTION buildNDIs(driverId, dis): # dis sorted chronologically
out = []
FOR i IN 1 .. len(dis)-1:
prev = dis[i-1]; next = dis[i]
out += NDI {
driverId : driverId
vehicleStart : prev.vehicleId
vehicleEnd : next.vehicleId
start : prev.end
end : next.start
pos : assignPos(prev, next)
cardOut : cardRemovalInterval(prev.end, next.start) # from C_ events
}
RETURN out
# The vehicle is stationary during the NDI -> assign the last known position
FUNCTION assignPos(prev, next):
RETURN prev.posEnd ?? next.posStart # nullable
# ═══════════ 2. Location Statistics + DBSCAN ═══════════
# Only NDIs > 7.5h with a known position are included in the statistics
FUNCTION clusterLongNDIs(allNDIs):
longs = [n IN allNDIs WHERE dur(n) > NDI_LONG AND n.pos != null]
labels = DBSCAN(points = [n.pos FOR n IN longs],
eps = DBSCAN_EPS, minPts = DBSCAN_MIN_PTS, metric = HAVERSINE)
FOR n, lbl IN zip(longs, labels):
n.cluster = lbl
RETURN longs # including the NOISE label
# ═══════════ 3. Determine Home Locations ═══════════
FUNCTION determineHomeLocations(longs):
# 3a Company home locations (depots): clusters containing > 25% of ALL long NDIs
totalCompany = count(longs) # Denominator = all long NDIs
companyHome = { c FOR (c, visits) IN groupByCluster(longs)
WHERE c != NOISE
AND count(visits) / totalCompany > VISIT_SHARE }
# 3b Driver home locations (private): for each driver, clusters containing
# > 25% of HIS/HER long NDIs, excluding clusters that overlap
# with the company clusters
driverHome = {} # driverId -> Set<cluster>
FOR (driverId, dnNDIs) IN groupByDriver(longs):
totalDriver = count(dnNDIs)
FOR (c, visits) IN groupByCluster(dnNDIs):
IF c != NOISE
AND count(visits) / totalDriver > VISIT_SHARE
AND c NOT IN companyHome: # Remove overlap with company locations
driverHome[driverId] += c
RETURN (companyHome, driverHome)
# ═══════════ 4. Home / NotHome Classification ═══════════
FUNCTION classify(n, companyHome, driverHome):
# A Card inserted into another vehicle at the end of the NDI
IF n.vehicleStart != n.vehicleEnd: RETURN HOME
# B Card removed for > 80% of the NDI
IF n.cardOut != null
AND dur(n.cardOut) > CARD_REMOVAL_PCT * dur(n): RETURN HOME
# C Rest > 24h
IF dur(n) > NDI_VERY_LONG: RETURN HOME
# D No position known
IF n.pos == null:
RETURN (dur(n) > NDI_LONG) ? HOME : NOT_HOME
# E Position known + long NDI -> decide using home-location clusters
IF dur(n) > NDI_LONG:
IF n.cluster IN companyHome
OR n.cluster IN driverHome[n.driverId]:
RETURN HOME # Depot / private home
ELSE:
RETURN NOT_HOME # Overnight stay in the vehicle
# Short rest near/at the vehicle
RETURN NOT_HOME
# ═══════════ 5. Border Crossings -> Trip Segments ═══════════
STRUCT TripSegment:
driverId; vehicleId
start; end
countryFrom; countryTo
posFrom; posTo
# BorderCrossing = explicit tachograph event (Smart Tachograph v2)
# OR country change in the reverse-geocoded GNSS trace
FUNCTION buildTripSegments(driverId, dis): # chronological
segs = []
segStart = dis[0].start
posFrom = dis[0].posStart
country = countryOf(dis[0].posStart) # PostGIS / Nominatim
FOR di IN dis:
FOR p IN di.gnssTrace: # (ts, x, y) from M_
c = countryOf(p)
IF c != country: # -> Border crossing
segs += TripSegment {
driverId : driverId
vehicleId : di.vehicleId
start : segStart
end : p.ts
countryFrom : country
countryTo : c
posFrom : posFrom
posTo : p
}
segStart = p.ts; posFrom = p; country = c
# Final segment (last section without another crossing)
segs += TripSegment {
driverId : driverId
vehicleId : dis[last].vehicleId
start : segStart
end : dis[last].end
countryFrom : country
countryTo : country
posFrom : posFrom
posTo : dis[last].posEnd
}
RETURN segs
# ═══════════ Orchestration ═══════════
FUNCTION run(files_M, files_C):
acts = parseTacho(files_M, files_C) # DIs + card events
allNDIs = []
segments = []
FOR (driverId, dis) IN groupDIsByDriver(acts):
dis = sortByTime(dis)
allNDIs += buildNDIs(driverId, dis)
segments += buildTripSegments(driverId, dis)
# Two passes: first cluster, then classify
longs = clusterLongNDIs(allNDIs)
(companyHome, driverHome) = determineHomeLocations(longs)
FOR n IN allNDIs:
n.status = classify(n, companyHome, driverHome)
RETURN (allNDIs, segments)
```

View File

@ -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.

View File

@ -0,0 +1,19 @@
package at.procon.eventhub.processing.eventprocessing.mixing;
/**
* Well-known generic classification dimensions understood by the runtime mixing engine.
*
* <p>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.</p>
*/
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() {
}
}

View File

@ -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);
}

View File

@ -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<String, RuntimeEventCompatibilityPolicy> policies;
@Autowired
public RuntimeEventCompatibilityPolicyRegistry(List<RuntimeEventCompatibilityPolicy> 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<String, RuntimeEventCompatibilityPolicy> policiesById(
List<RuntimeEventCompatibilityPolicy> policies
) {
Map<String, RuntimeEventCompatibilityPolicy> 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);
}
}

View File

@ -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) {

View File

@ -15,17 +15,13 @@ import org.springframework.stereotype.Component;
@Component
public class RuntimeEventDescriptorFactory {
private final RuntimeTachographEventSemantics tachographSemantics;
private final List<RuntimeEventSemantics> semanticsAdapters;
@Autowired
public RuntimeEventDescriptorFactory(RuntimeTachographEventSemantics tachographSemantics) {
this.tachographSemantics = tachographSemantics;
public RuntimeEventDescriptorFactory(List<RuntimeEventSemantics> semanticsAdapters) {
this.semanticsAdapters = semanticsAdapters == null ? List.of() : List.copyOf(semanticsAdapters);
}
/** Compatibility constructor used by unit tests. */
public RuntimeEventDescriptorFactory() {
this(new RuntimeTachographEventSemantics());
}
public List<RuntimeEventDescriptor> describeSorted(List<EventHubEventDto> 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;

View File

@ -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.
*
* <p>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.</p>
*/
/** 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);
}
}

View File

@ -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<EventHubEventDto> secondaries);
}

View File

@ -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<String, RuntimeEventFusionPolicy> policies;
@Autowired
public RuntimeEventFusionPolicyRegistry(List<RuntimeEventFusionPolicy> policies) {
Map<String, RuntimeEventFusionPolicy> 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<EventHubEventDto> 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);
}
}

View File

@ -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<String, Integer> sourceFamilyCounts,
Map<String, Integer> sourceRoleCounts,
Map<String, Integer> 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<String, Integer> counts, String value) {
return value == null || counts == null ? 0 : counts.getOrDefault(value, 0);
}
}

View File

@ -5,20 +5,25 @@ import at.procon.eventhub.dto.EventLifecycle;
import at.procon.eventhub.dto.EventType;
import java.util.Set;
/**
* Generic event-mixing rule.
*
* <p>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.</p>
*/
public record RuntimeEventMixingRule(
String ruleId,
RuntimeEventMixingChannel channel,
String equivalenceType,
String compatibilityPolicyId,
String fusionPolicyId,
Set<EventDomain> eventDomains,
Set<EventType> eventTypes,
Set<EventLifecycle> lifecycles,
Set<String> primaryExtractionCodes,
Set<String> secondaryExtractionCodes,
Set<RuntimeTachographEvidenceSourceRole> primarySourceRoles,
Set<RuntimeTachographEvidenceSourceRole> secondarySourceRoles,
Set<RuntimeTachographRepresentation> primaryRepresentations,
Set<RuntimeTachographRepresentation> 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<String> extractionCodes,
Set<RuntimeTachographEvidenceSourceRole> sourceRoles,
Set<RuntimeTachographRepresentation> 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<String> normalize(Set<String> 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);
}
}

View File

@ -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<RuntimeEventMixingRule> rulesForMode(String mode);
}

View File

@ -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<EventDomain> SUPPORT_EVENT_DOMAINS = Set.of(
EventDomain.POSITION,
EventDomain.PLACE,
EventDomain.BORDER_CROSSING,
EventDomain.LOAD_UNLOAD,
EventDomain.SPECIFIC_CONDITION
);
private final List<RuntimeEventMixingRuleProvider> providers;
private static final Set<EventType> 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<RuntimeEventMixingRuleProvider> providers) {
this.providers = providers == null ? List.of() : List.copyOf(providers);
}
private static final Set<EventLifecycle> SUPPORT_EVENT_LIFECYCLES = Set.of(
EventLifecycle.SNAPSHOT,
EventLifecycle.START,
EventLifecycle.BEGIN,
EventLifecycle.END,
EventLifecycle.INBOUND,
EventLifecycle.OUTBOUND,
EventLifecycle.OUT_EU
);
private static final Set<EventType> ACTIVITY_EVENT_TYPES = Set.of(
EventType.DRIVE,
EventType.BREAK_REST,
EventType.AVAILABILITY,
EventType.WORK,
EventType.UNKNOWN_ACTIVITY
);
private static final Set<String> CARD_SUPPORT_EXTRACTION_CODES = Set.of(
"CARD_POSITION",
"CARD_PLACE",
"CARD_BORDER_CROSSING",
"CARD_LOAD_UNLOAD",
"CARD_SPECIFIC_CONDITION"
);
private static final Set<String> VU_SUPPORT_EXTRACTION_CODES = Set.of(
"VU_POSITION",
"VU_PLACE",
"VU_BORDER_CROSSING",
"VU_LOAD_UNLOAD",
"VU_SPECIFIC_CONDITION"
);
private static final Set<RuntimeTachographEvidenceSourceRole> BOTH_TACHOGRAPH_ROLES = Set.of(
RuntimeTachographEvidenceSourceRole.DRIVER_CARD,
RuntimeTachographEvidenceSourceRole.VEHICLE_UNIT
);
public List<RuntimeEventMixingRule> 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();
}
}

View File

@ -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<EventHubEventDto> 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<EventHubEventDto> 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<RuntimeEventDescriptor> 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<RuntimeEventDescriptor> 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<String, Integer> classificationCounts(
List<RuntimeEventDescriptor> descriptors,
String classificationKey
) {
Map<String, Integer> 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<EventHubEventDto> 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 {

View File

@ -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<String> 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;
}
}

View File

@ -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<String, Set<String>> acceptedClassifications) {
public static final RuntimeEventSelector ANY = new RuntimeEventSelector(Map.of());
public RuntimeEventSelector {
Map<String, Set<String>> normalized = new LinkedHashMap<>();
if (acceptedClassifications != null) {
acceptedClassifications.forEach((key, values) -> {
String normalizedKey = normalizeKey(key);
Set<String> 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<String, Set<String>> 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<String> values) {
return new RuntimeEventSelector(Map.of(key, values));
}
public static RuntimeEventSelector of(Map<String, Set<String>> classifications) {
return new RuntimeEventSelector(classifications);
}
private static Set<String> normalizeValues(Set<String> 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);
}
}

View File

@ -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();
}
}

View File

@ -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.
*
* <p>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.</p>
*/
public record RuntimeEventSourceProfile(
String sourceSystem,
String sourceKind,
String extractionCode,
RuntimeTachographEvidenceSourceRole evidenceSourceRole,
RuntimeTachographRepresentation representation
Map<String, String> 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<String, String> 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);
}
}

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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<EventDomain> SUPPORT_EVENT_DOMAINS = Set.of(
EventDomain.POSITION,
EventDomain.PLACE,
EventDomain.BORDER_CROSSING,
EventDomain.LOAD_UNLOAD,
EventDomain.SPECIFIC_CONDITION
);
private static final Set<EventType> 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<EventLifecycle> SUPPORT_EVENT_LIFECYCLES = Set.of(
EventLifecycle.SNAPSHOT,
EventLifecycle.START,
EventLifecycle.BEGIN,
EventLifecycle.END,
EventLifecycle.INBOUND,
EventLifecycle.OUTBOUND,
EventLifecycle.OUT_EU
);
private static final Set<EventType> ACTIVITY_EVENT_TYPES = Set.of(
EventType.DRIVE,
EventType.BREAK_REST,
EventType.AVAILABILITY,
EventType.WORK,
EventType.UNKNOWN_ACTIVITY
);
private static final Set<String> CARD_SUPPORT_EXTRACTION_CODES = Set.of(
"CARD_POSITION",
"CARD_PLACE",
"CARD_BORDER_CROSSING",
"CARD_LOAD_UNLOAD",
"CARD_SPECIFIC_CONDITION"
);
private static final Set<String> VU_SUPPORT_EXTRACTION_CODES = Set.of(
"VU_POSITION",
"VU_PLACE",
"VU_BORDER_CROSSING",
"VU_LOAD_UNLOAD",
"VU_SPECIFIC_CONDITION"
);
@Override
public List<RuntimeEventMixingRule> 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<EventDomain> domains,
Set<EventType> types,
Set<EventLifecycle> 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<String> roles,
Set<String> representations,
Set<String> extractionCodes
) {
Map<String, Set<String>> 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);
}
}

View File

@ -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.</p>
*/
@Component
public class RuntimeTachographEventSemantics {
public class RuntimeTachographEventSemantics implements RuntimeEventSemantics {
private static final Set<String> 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<String, String> 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(

View File

@ -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;
}
}

View File

@ -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<EventHubEventDto> 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;
}
}

View File

@ -12,6 +12,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
@ -27,7 +28,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Vehicle usage merge",
"Merges adjacent or continuous effective same-driver/same-vehicle usage intervals after source reconciliation.",
"Merges adjacent or overlapping effective same-driver/same-vehicle usage intervals after source reconciliation, including complementary representations where one side lacks VIN or another identity field.",
"JAVA",
Set.of(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION),
Set.of("RuntimeVehicleUsageReconciliationResult.effectiveVehicleUsageIntervals"),
@ -43,6 +44,7 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("inputIntervalCount", intervals.size());
metadata.put("mergedIntervalCount", merged.size());
metadata.put("coalescedIntervalCount", Math.max(0, intervals.size() - merged.size()));
return new RuntimeProcessingModuleResult(
moduleKey(),
RuntimeProcessingModuleStatus.SUCCESS,
@ -100,42 +102,248 @@ public class DriverVehicleUsageMergeModule implements RuntimeProcessingModule {
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
if (left == null || right == null || left.endedAt() == null || right.startedAt() == null) {
if (left == null || right == null
|| left.startedAt() == null || left.endedAt() == null
|| right.startedAt() == null || right.endedAt() == null) {
return false;
}
return Objects.equals(left.driverKey(), right.driverKey())
&& Objects.equals(left.registrationKey(), right.registrationKey())
&& Objects.equals(left.vehicleKey(), right.vehicleKey())
&& compatibleVehicleIdentity(left, right)
&& compatibleAlignedBoundaryOdometers(left, right)
&& !right.startedAt().isAfter(left.endedAt().plusSeconds(1));
}
private boolean compatibleVehicleIdentity(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
boolean registrationCompatible = compatibleNullable(left.registrationKey(), right.registrationKey());
boolean vehicleCompatible = compatibleNullable(left.vehicleKey(), right.vehicleKey());
if (!registrationCompatible || !vehicleCompatible) {
return false;
}
boolean sameRegistration = hasText(left.registrationKey())
&& hasText(right.registrationKey())
&& Objects.equals(left.registrationKey(), right.registrationKey());
boolean sameVehicle = hasText(left.vehicleKey())
&& hasText(right.vehicleKey())
&& Objects.equals(left.vehicleKey(), right.vehicleKey());
boolean bothUnidentified = !hasVehicleIdentity(left) && !hasVehicleIdentity(right);
return sameRegistration || sameVehicle || bothUnidentified;
}
private boolean compatibleAlignedBoundaryOdometers(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
if (Objects.equals(left.startedAt(), right.startedAt())
&& materiallyDifferent(left.odometerBeginKm(), right.odometerBeginKm())) {
return false;
}
return !Objects.equals(left.endedAt(), right.endedAt())
|| !materiallyDifferent(left.odometerEndKm(), right.odometerEndKm());
}
private boolean materiallyDifferent(Long left, Long right) {
return left != null && right != null && Math.abs(left - right) > 1L;
}
private boolean compatibleNullable(String left, String right) {
return !hasText(left) || !hasText(right) || Objects.equals(left, right);
}
private boolean hasVehicleIdentity(DriverWorkingTimeVehicleUsageInterval interval) {
return interval != null && (hasText(interval.registrationKey()) || hasText(interval.vehicleKey()));
}
private boolean hasText(String value) {
return value != null && !value.isBlank();
}
private DriverWorkingTimeVehicleUsageInterval merge(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
LinkedHashSet<String> sourceIntervalIds = new LinkedHashSet<>(left.sourceIntervalIds());
sourceIntervalIds.addAll(right.sourceIntervalIds());
OffsetDateTime mergedEnd = left.endedAt();
if (right.endedAt() != null && (mergedEnd == null || right.endedAt().isAfter(mergedEnd))) {
mergedEnd = right.endedAt();
}
DriverWorkingTimeVehicleUsageInterval primary = richer(left, right);
DriverWorkingTimeVehicleUsageInterval secondary = primary == left ? right : left;
OffsetDateTime mergedStart = earliest(left.startedAt(), right.startedAt());
OffsetDateTime mergedEnd = latest(left.endedAt(), right.endedAt());
LinkedHashSet<String> sourceIntervalIds = new LinkedHashSet<>();
addSourceIds(sourceIntervalIds, left);
addSourceIds(sourceIntervalIds, right);
return new DriverWorkingTimeVehicleUsageInterval(
left.sessionId(),
left.driverKey(),
left.intervalId(),
left.firstSourceIntervalId(),
right.lastSourceIntervalId() == null ? left.lastSourceIntervalId() : right.lastSourceIntervalId(),
left.startedAt(),
firstNonNull(primary.sessionId(), secondary.sessionId()),
firstNonBlank(primary.driverKey(), secondary.driverKey()),
firstNonBlank(primary.intervalId(), secondary.intervalId()),
firstSourceIntervalId(left, right, mergedStart),
lastSourceIntervalId(left, right, mergedEnd),
mergedStart,
mergedEnd,
left.startedAtEpochSecond(),
mergedEnd == null ? null : mergedEnd.toEpochSecond(),
mergedEnd == null ? left.durationSeconds() : mergedEnd.toEpochSecond() - left.startedAtEpochSecond(),
left.odometerBeginKm(),
right.odometerEndKm() == null ? left.odometerEndKm() : right.odometerEndKm(),
left.registrationKey(),
left.vehicleKey(),
left.sourceKind(),
mergedStart.toEpochSecond(),
mergedEnd.toEpochSecond(),
mergedEnd.toEpochSecond() - mergedStart.toEpochSecond(),
odometerAtStart(left, right, mergedStart, primary),
odometerAtEnd(left, right, mergedEnd, primary),
firstNonBlank(primary.registrationKey(), secondary.registrationKey()),
firstNonBlank(primary.vehicleKey(), secondary.vehicleKey()),
firstNonBlank(primary.sourceKind(), secondary.sourceKind()),
List.copyOf(sourceIntervalIds)
);
}
private DriverWorkingTimeVehicleUsageInterval richer(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
int leftScore = richnessScore(left);
int rightScore = richnessScore(right);
if (rightScore > leftScore) {
return right;
}
if (leftScore > rightScore) {
return left;
}
return preferStableSession(right.sessionId(), left.sessionId()) ? right : left;
}
private int richnessScore(DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return Integer.MIN_VALUE;
}
int score = 0;
if (hasText(interval.vehicleKey())) {
score += 8;
}
if (hasText(interval.registrationKey())) {
score += 4;
}
if (interval.odometerBeginKm() != null) {
score += 2;
}
if (interval.odometerEndKm() != null) {
score += 2;
}
if (hasText(interval.sourceKind())) {
score += 1;
}
return score;
}
private boolean preferStableSession(UUID candidate, UUID current) {
if (candidate == null) {
return false;
}
if (current == null) {
return true;
}
return isZero(candidate) && !isZero(current);
}
private boolean isZero(UUID value) {
return value != null && value.getMostSignificantBits() == 0L && value.getLeastSignificantBits() == 0L;
}
private String firstSourceIntervalId(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedStart
) {
DriverWorkingTimeVehicleUsageInterval owner = Objects.equals(left.startedAt(), right.startedAt())
? richer(left, right)
: (Objects.equals(left.startedAt(), mergedStart) ? left : right);
DriverWorkingTimeVehicleUsageInterval other = owner == left ? right : left;
return firstNonBlank(owner.firstSourceIntervalId(), owner.intervalId(), other.firstSourceIntervalId(), other.intervalId());
}
private String lastSourceIntervalId(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedEnd
) {
DriverWorkingTimeVehicleUsageInterval owner = Objects.equals(left.endedAt(), right.endedAt())
? richer(left, right)
: (Objects.equals(left.endedAt(), mergedEnd) ? left : right);
DriverWorkingTimeVehicleUsageInterval other = owner == left ? right : left;
return firstNonBlank(owner.lastSourceIntervalId(), owner.intervalId(), other.lastSourceIntervalId(), other.intervalId());
}
private Long odometerAtStart(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedStart,
DriverWorkingTimeVehicleUsageInterval primary
) {
if (Objects.equals(left.startedAt(), right.startedAt())) {
DriverWorkingTimeVehicleUsageInterval secondary = primary == left ? right : left;
return firstNonNull(primary.odometerBeginKm(), secondary.odometerBeginKm());
}
return Objects.equals(left.startedAt(), mergedStart) ? left.odometerBeginKm() : right.odometerBeginKm();
}
private Long odometerAtEnd(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right,
OffsetDateTime mergedEnd,
DriverWorkingTimeVehicleUsageInterval primary
) {
if (Objects.equals(left.endedAt(), right.endedAt())) {
DriverWorkingTimeVehicleUsageInterval secondary = primary == left ? right : left;
return firstNonNull(primary.odometerEndKm(), secondary.odometerEndKm());
}
return Objects.equals(left.endedAt(), mergedEnd) ? left.odometerEndKm() : right.odometerEndKm();
}
private OffsetDateTime earliest(OffsetDateTime left, OffsetDateTime right) {
return left.isBefore(right) ? left : right;
}
private OffsetDateTime latest(OffsetDateTime left, OffsetDateTime right) {
return left.isAfter(right) ? left : right;
}
private void addSourceIds(LinkedHashSet<String> ids, DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return;
}
add(ids, interval.intervalId());
add(ids, interval.firstSourceIntervalId());
add(ids, interval.lastSourceIntervalId());
if (interval.sourceIntervalIds() != null) {
interval.sourceIntervalIds().forEach(value -> add(ids, value));
}
}
private void add(LinkedHashSet<String> values, String value) {
if (hasText(value)) {
values.add(value);
}
}
@SafeVarargs
private final <T> T firstNonNull(T... values) {
if (values == null) {
return null;
}
for (T value : values) {
if (value != null) {
return value;
}
}
return null;
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (hasText(value)) {
return value;
}
}
return null;
}
}

View File

@ -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();
}
}

View File

@ -36,7 +36,7 @@ public class VehicleUsageReconciliationModule implements RuntimeProcessingModule
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Vehicle usage reconciliation",
"Normalizes CARD_VEHICLES_USED technical midnight splits, then reconciles normalized CARD_VEHICLES_USED intervals with IW_CYCLE intervals. IW_CYCLE is primary for effective vehicle usage; CARD_VEHICLES_USED remains fallback or corroborating evidence.",
"Normalizes CARD_VEHICLES_USED technical midnight splits independently per physical representation, reconciles equivalent database/file-session CVU intervals, and then reconciles effective CVU intervals with IW_CYCLE.",
"JAVA",
Set.of(DriverWorkingTimeModuleKeys.EVENT_TO_VEHICLE_USAGE_INTERVALS),
Set.of("DriverVehicleUsageIntervalEvent"),
@ -51,8 +51,13 @@ public class VehicleUsageReconciliationModule implements RuntimeProcessingModule
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("inputVehicleUsageIntervalCount", result.rawVehicleUsageIntervals().size());
metadata.put("normalizedCardVehicleUsedIntervalCount", result.normalizedCardVehicleUsedIntervals().size());
metadata.put("representationReconciledCardVehicleUsedIntervalCount", result.representationReconciledCardVehicleUsedIntervals().size());
metadata.put("effectiveVehicleUsageIntervalCount", result.effectiveVehicleUsageIntervals().size());
metadata.put("suppressedSecondaryIntervalCount", result.suppressedSecondaryIntervals().size());
metadata.put("crossRepresentationSuppressedCvuIntervalCount", result.vehicleUsageReconciliationDecisions().stream()
.filter(decision -> RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT.equals(decision.ruleId()))
.mapToInt(decision -> decision.secondaryIntervalIds().size())
.sum());
metadata.put("vehicleUsageReconciliationDecisionCount", result.vehicleUsageReconciliationDecisions().size());
metadata.put("vehicleUsageReconciliationDecisions", result.vehicleUsageReconciliationDecisions());
metadata.put("notes", result.notes());

View File

@ -6,6 +6,7 @@ import java.time.OffsetDateTime;
public record RuntimeVehicleUsageIntervalDescriptor(
DriverWorkingTimeVehicleUsageInterval interval,
RuntimeVehicleUsageIntervalSourceType sourceType,
RuntimeVehicleUsageIntervalRepresentation representation,
String sourceKind,
String driverKey,
String registrationKey,

View File

@ -4,11 +4,14 @@ import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVe
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.stereotype.Component;
@Component
public class RuntimeVehicleUsageIntervalDescriptorFactory {
private static final UUID ZERO_UUID = new UUID(0L, 0L);
public RuntimeVehicleUsageIntervalDescriptor describe(DriverWorkingTimeVehicleUsageInterval interval) {
if (interval == null) {
return null;
@ -16,6 +19,7 @@ public class RuntimeVehicleUsageIntervalDescriptorFactory {
return new RuntimeVehicleUsageIntervalDescriptor(
interval,
sourceType(interval),
representation(interval),
interval.sourceKind(),
interval.driverKey(),
interval.registrationKey(),
@ -41,6 +45,24 @@ public class RuntimeVehicleUsageIntervalDescriptorFactory {
return RuntimeVehicleUsageIntervalSourceType.OTHER;
}
private RuntimeVehicleUsageIntervalRepresentation representation(DriverWorkingTimeVehicleUsageInterval interval) {
UUID sessionId = interval.sessionId();
if (sessionId != null) {
return ZERO_UUID.equals(sessionId)
? RuntimeVehicleUsageIntervalRepresentation.DATABASE
: RuntimeVehicleUsageIntervalRepresentation.FILE_SESSION;
}
List<String> identifiers = identifiers(interval).stream().map(this::normalize).toList();
if (identifiers.stream().anyMatch(value -> value.startsWith("TACHOGRAPH:"))) {
return RuntimeVehicleUsageIntervalRepresentation.DATABASE;
}
if (identifiers.stream().anyMatch(value -> value.startsWith("CVU-") || value.startsWith("VUIW-"))) {
return RuntimeVehicleUsageIntervalRepresentation.FILE_SESSION;
}
return RuntimeVehicleUsageIntervalRepresentation.UNKNOWN;
}
private List<String> identifiers(DriverWorkingTimeVehicleUsageInterval interval) {
List<String> result = new ArrayList<>();
add(result, interval.intervalId());

View File

@ -0,0 +1,8 @@
package at.procon.eventhub.processing.eventprocessing.vehicleusage;
/** Physical representation from which a reconstructed vehicle-usage interval originated. */
public enum RuntimeVehicleUsageIntervalRepresentation {
DATABASE,
FILE_SESSION,
UNKNOWN
}

View File

@ -6,6 +6,7 @@ import java.util.List;
public record RuntimeVehicleUsageReconciliationResult(
List<DriverWorkingTimeVehicleUsageInterval> rawVehicleUsageIntervals,
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardVehicleUsedIntervals,
List<DriverWorkingTimeVehicleUsageInterval> representationReconciledCardVehicleUsedIntervals,
List<DriverWorkingTimeVehicleUsageInterval> effectiveVehicleUsageIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedSecondaryIntervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> vehicleUsageReconciliationDecisions,
@ -15,6 +16,9 @@ public record RuntimeVehicleUsageReconciliationResult(
public RuntimeVehicleUsageReconciliationResult {
rawVehicleUsageIntervals = rawVehicleUsageIntervals == null ? List.of() : List.copyOf(rawVehicleUsageIntervals);
normalizedCardVehicleUsedIntervals = normalizedCardVehicleUsedIntervals == null ? List.of() : List.copyOf(normalizedCardVehicleUsedIntervals);
representationReconciledCardVehicleUsedIntervals = representationReconciledCardVehicleUsedIntervals == null
? List.of()
: List.copyOf(representationReconciledCardVehicleUsedIntervals);
effectiveVehicleUsageIntervals = effectiveVehicleUsageIntervals == null ? List.of() : List.copyOf(effectiveVehicleUsageIntervals);
suppressedSecondaryIntervals = suppressedSecondaryIntervals == null ? List.of() : List.copyOf(suppressedSecondaryIntervals);
vehicleUsageReconciliationDecisions = vehicleUsageReconciliationDecisions == null ? List.of() : List.copyOf(vehicleUsageReconciliationDecisions);

View File

@ -5,6 +5,7 @@ import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@ -18,6 +19,8 @@ import org.springframework.stereotype.Service;
public class RuntimeVehicleUsageReconciliationService {
public static final String RULE_CVU_MIDNIGHT_CONTINUATION = "tachograph.vehicle-usage.card-vehicles-used.midnight-continuation";
public static final String RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT =
"tachograph.vehicle-usage.card-vehicles-used.cross-representation-equivalent";
public static final String RULE_CVU_IW_EXACT_OR_COMPATIBLE = "tachograph.vehicle-usage.card-vehicles-used-iw-cycle.exact-or-compatible";
public static final String RULE_CVU_FALLBACK = "tachograph.vehicle-usage.card-vehicles-used-fallback";
public static final String RULE_IW_PRIMARY = "tachograph.vehicle-usage.iw-cycle-primary";
@ -57,28 +60,71 @@ public class RuntimeVehicleUsageReconciliationService {
}
}
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals = normalizeCardVehicleUsed(cardIntervals, decisions);
ReconciliationStepResult reconciled = reconcileCardWithIw(normalizedCardIntervals, iwIntervals, decisions, warnings);
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals =
normalizeCardVehicleUsedByRepresentation(cardIntervals, decisions);
CrossRepresentationStepResult representationReconciled =
reconcileCardRepresentations(normalizedCardIntervals, decisions, warnings);
ReconciliationStepResult reconciled = reconcileCardWithIw(
representationReconciled.effectiveIntervals(),
iwIntervals,
decisions,
warnings
);
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
effective.addAll(reconciled.effectiveIntervals());
effective.addAll(otherIntervals);
effective.sort(intervalComparator());
List<DriverWorkingTimeVehicleUsageInterval> suppressed = new ArrayList<>();
suppressed.addAll(representationReconciled.suppressedIntervals());
suppressed.addAll(reconciled.suppressedIntervals());
suppressed.sort(intervalComparator());
List<String> notes = List.of(
"Vehicle usage reconciliation first coalesced CARD_VEHICLES_USED technical midnight splits, then reconciled normalized CARD_VEHICLES_USED intervals with IW_CYCLE intervals."
"Vehicle usage reconciliation coalesced CARD_VEHICLES_USED technical midnight splits independently per physical representation, reconciled exact and one-sided boundary-censored database/file-session CVU intervals, and only then reconciled effective CVU intervals with IW_CYCLE intervals."
);
return new RuntimeVehicleUsageReconciliationResult(
raw,
normalizedCardIntervals,
representationReconciled.effectiveIntervals(),
effective,
reconciled.suppressedIntervals(),
suppressed,
decisions,
notes,
warnings
);
}
private List<DriverWorkingTimeVehicleUsageInterval> normalizeCardVehicleUsedByRepresentation(
List<DriverWorkingTimeVehicleUsageInterval> intervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions
) {
if (intervals == null || intervals.isEmpty()) {
return List.of();
}
Map<RuntimeVehicleUsageIntervalRepresentation, List<DriverWorkingTimeVehicleUsageInterval>> byRepresentation =
new EnumMap<>(RuntimeVehicleUsageIntervalRepresentation.class);
for (DriverWorkingTimeVehicleUsageInterval interval : intervals) {
RuntimeVehicleUsageIntervalDescriptor descriptor = descriptorFactory.describe(interval);
RuntimeVehicleUsageIntervalRepresentation representation = descriptor == null
? RuntimeVehicleUsageIntervalRepresentation.UNKNOWN
: descriptor.representation();
byRepresentation.computeIfAbsent(representation, ignored -> new ArrayList<>()).add(interval);
}
List<DriverWorkingTimeVehicleUsageInterval> normalized = new ArrayList<>();
for (RuntimeVehicleUsageIntervalRepresentation representation : RuntimeVehicleUsageIntervalRepresentation.values()) {
List<DriverWorkingTimeVehicleUsageInterval> representationIntervals = byRepresentation.get(representation);
if (representationIntervals == null || representationIntervals.isEmpty()) {
continue;
}
normalized.addAll(normalizeCardVehicleUsed(representationIntervals, decisions));
}
normalized.sort(intervalComparator());
return List.copyOf(normalized);
}
private List<DriverWorkingTimeVehicleUsageInterval> normalizeCardVehicleUsed(
List<DriverWorkingTimeVehicleUsageInterval> intervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions
@ -122,6 +168,250 @@ public class RuntimeVehicleUsageReconciliationService {
return List.copyOf(normalized);
}
private CrossRepresentationStepResult reconcileCardRepresentations(
List<DriverWorkingTimeVehicleUsageInterval> normalizedCardIntervals,
List<RuntimeVehicleUsageReconciliationDecisionDto> decisions,
List<String> warnings
) {
if (normalizedCardIntervals == null || normalizedCardIntervals.isEmpty()) {
return new CrossRepresentationStepResult(List.of(), List.of());
}
List<DriverWorkingTimeVehicleUsageInterval> databaseIntervals = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> fileSessionIntervals = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> unknownIntervals = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval interval : normalizedCardIntervals) {
RuntimeVehicleUsageIntervalDescriptor descriptor = descriptorFactory.describe(interval);
RuntimeVehicleUsageIntervalRepresentation representation = descriptor == null
? RuntimeVehicleUsageIntervalRepresentation.UNKNOWN
: descriptor.representation();
switch (representation) {
case DATABASE -> databaseIntervals.add(interval);
case FILE_SESSION -> fileSessionIntervals.add(interval);
case UNKNOWN -> unknownIntervals.add(interval);
}
}
List<DriverWorkingTimeVehicleUsageInterval> effective = new ArrayList<>();
List<DriverWorkingTimeVehicleUsageInterval> suppressed = new ArrayList<>();
Set<String> usedFileSessionKeys = new LinkedHashSet<>();
for (DriverWorkingTimeVehicleUsageInterval database : databaseIntervals) {
List<DriverWorkingTimeVehicleUsageInterval> matchingFileIntervals = fileSessionIntervals.stream()
.filter(Objects::nonNull)
.filter(file -> !usedFileSessionKeys.contains(intervalIdentity(file)))
.filter(file -> canReconcileCardRepresentations(database, file))
.sorted(Comparator.comparingLong(file -> crossRepresentationMatchScore(database, file)))
.toList();
if (matchingFileIntervals.isEmpty()) {
effective.add(database);
continue;
}
DriverWorkingTimeVehicleUsageInterval fused = database;
List<String> secondaryIds = new ArrayList<>();
List<String> secondaryTypes = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval file : matchingFileIntervals) {
usedFileSessionKeys.add(intervalIdentity(file));
suppressed.add(file);
secondaryIds.add(file.intervalId());
secondaryTypes.add(RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name()
+ ":" + RuntimeVehicleUsageIntervalRepresentation.FILE_SESSION.name());
fused = fuseCardRepresentationPrimary(fused, file);
}
effective.add(fused);
List<String> conflictWarnings = new ArrayList<>();
for (DriverWorkingTimeVehicleUsageInterval file : matchingFileIntervals) {
conflictWarnings.addAll(vehicleConflictWarnings(file, database));
}
warnings.addAll(conflictWarnings);
decisions.add(new RuntimeVehicleUsageReconciliationDecisionDto(
RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT,
"CROSS_REPRESENTATION_DUPLICATE_SUPPRESSED",
matchingFileIntervals.size() == 1
? equivalenceType(database, matchingFileIntervals.getFirst())
: "MULTIPLE_EQUIVALENT_FILE_SESSION_INTERVALS",
fused.intervalId(),
RuntimeVehicleUsageIntervalSourceType.CARD_VEHICLES_USED.name()
+ ":" + RuntimeVehicleUsageIntervalRepresentation.DATABASE.name(),
secondaryIds,
secondaryTypes,
fused.startedAt(),
fused.endedAt(),
absoluteDeltaSeconds(database.startedAt(), matchingFileIntervals.getFirst().startedAt()),
absoluteDeltaSeconds(database.endedAt(), matchingFileIntervals.getFirst().endedAt()),
"Database and file-session CARD_VEHICLES_USED intervals describe the same normalized vehicle-usage period. Database identity remains primary; a wider one-sided boundary from the corroborating representation is preserved when the corresponding odometer boundary is unobserved.",
conflictWarnings
));
}
for (DriverWorkingTimeVehicleUsageInterval file : fileSessionIntervals) {
if (!usedFileSessionKeys.contains(intervalIdentity(file))) {
effective.add(file);
}
}
effective.addAll(unknownIntervals);
effective.sort(intervalComparator());
suppressed.sort(intervalComparator());
return new CrossRepresentationStepResult(List.copyOf(effective), List.copyOf(suppressed));
}
private boolean canReconcileCardRepresentations(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession
) {
if (database == null || fileSession == null
|| database.startedAt() == null || database.endedAt() == null
|| fileSession.startedAt() == null || fileSession.endedAt() == null) {
return false;
}
if (!Objects.equals(database.driverKey(), fileSession.driverKey())) {
return false;
}
if (!compatibleVehicleIdentity(database, fileSession)) {
return false;
}
if (!compatibleOdometer(database.odometerBeginKm(), fileSession.odometerBeginKm())
|| !compatibleOdometer(database.odometerEndKm(), fileSession.odometerEndKm())) {
return false;
}
return compatibleCrossRepresentationBoundaries(database, fileSession);
}
private boolean compatibleCrossRepresentationBoundaries(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
long startDelta = absoluteDeltaSeconds(left.startedAt(), right.startedAt());
long endDelta = absoluteDeltaSeconds(left.endedAt(), right.endedAt());
boolean startAligned = startDelta <= MATCH_TOLERANCE_SECONDS;
boolean endAligned = endDelta <= MATCH_TOLERANCE_SECONDS;
if (startAligned && endAligned) {
return true;
}
if (!intervalsOverlap(left, right)) {
return false;
}
// A missing odometer at a non-aligned boundary means that the representation
// did not observe the physical start/end and was clipped by its load window.
// The opposite aligned boundary still identifies the same physical CVU period.
if (endAligned && !startAligned) {
return left.odometerBeginKm() == null || right.odometerBeginKm() == null;
}
if (startAligned && !endAligned) {
return left.odometerEndKm() == null || right.odometerEndKm() == null;
}
return false;
}
private boolean intervalsOverlap(
DriverWorkingTimeVehicleUsageInterval left,
DriverWorkingTimeVehicleUsageInterval right
) {
return left.startedAt().isBefore(right.endedAt())
&& right.startedAt().isBefore(left.endedAt());
}
private DriverWorkingTimeVehicleUsageInterval fuseCardRepresentationPrimary(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession
) {
long startDelta = absoluteDeltaSeconds(database.startedAt(), fileSession.startedAt());
long endDelta = absoluteDeltaSeconds(database.endedAt(), fileSession.endedAt());
OffsetDateTime start = startDelta <= MATCH_TOLERANCE_SECONDS
? database.startedAt()
: earliest(database.startedAt(), fileSession.startedAt());
OffsetDateTime end = endDelta <= MATCH_TOLERANCE_SECONDS
? database.endedAt()
: latest(database.endedAt(), fileSession.endedAt());
Long odometerBegin = boundaryOdometerAtStart(database, fileSession, start, startDelta);
Long odometerEnd = boundaryOdometerAtEnd(database, fileSession, end, endDelta);
Long startedAtEpochSecond = start == null ? null : start.toEpochSecond();
Long endedAtEpochSecond = end == null ? null : end.toEpochSecond();
Long durationSeconds = startedAtEpochSecond == null || endedAtEpochSecond == null
? database.durationSeconds()
: endedAtEpochSecond - startedAtEpochSecond;
return new DriverWorkingTimeVehicleUsageInterval(
database.sessionId(),
firstNonBlank(database.driverKey(), fileSession.driverKey()),
database.intervalId(),
firstNonBlank(database.firstSourceIntervalId(), database.intervalId()),
firstNonBlank(database.lastSourceIntervalId(), database.intervalId()),
start,
end,
startedAtEpochSecond,
endedAtEpochSecond,
durationSeconds,
odometerBegin,
odometerEnd,
firstNonBlank(database.registrationKey(), fileSession.registrationKey()),
firstNonBlank(database.vehicleKey(), fileSession.vehicleKey()),
firstNonBlank(database.sourceKind(), fileSession.sourceKind()),
sourceIntervalIds(database, fileSession)
);
}
private Long boundaryOdometerAtStart(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession,
OffsetDateTime selectedStart,
long startDelta
) {
if (startDelta <= MATCH_TOLERANCE_SECONDS) {
return firstNonNull(database.odometerBeginKm(), fileSession.odometerBeginKm());
}
if (Objects.equals(selectedStart, database.startedAt())) {
return database.odometerBeginKm();
}
return fileSession.odometerBeginKm();
}
private Long boundaryOdometerAtEnd(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession,
OffsetDateTime selectedEnd,
long endDelta
) {
if (endDelta <= MATCH_TOLERANCE_SECONDS) {
return firstNonNull(database.odometerEndKm(), fileSession.odometerEndKm());
}
if (Objects.equals(selectedEnd, database.endedAt())) {
return database.odometerEndKm();
}
return fileSession.odometerEndKm();
}
private OffsetDateTime earliest(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isBefore(right) ? left : right;
}
private OffsetDateTime latest(OffsetDateTime left, OffsetDateTime right) {
if (left == null) {
return right;
}
if (right == null) {
return left;
}
return left.isAfter(right) ? left : right;
}
private boolean compatibleOdometer(Long left, Long right) {
return left == null || right == null || Math.abs(left - right) <= 1L;
}
private ReconciliationStepResult reconcileCardWithIw(
List<DriverWorkingTimeVehicleUsageInterval> cardIntervals,
List<DriverWorkingTimeVehicleUsageInterval> iwIntervals,
@ -352,9 +642,36 @@ public class RuntimeVehicleUsageReconciliationService {
if ((start == null || start == 0L) && (end == null || end == 0L)) {
return "EXACT_INTERVAL_BOUNDARIES";
}
if (start != null && end != null) {
boolean startAligned = start <= MATCH_TOLERANCE_SECONDS;
boolean endAligned = end <= MATCH_TOLERANCE_SECONDS;
if (!startAligned && endAligned
&& (card.odometerBeginKm() == null || iw.odometerBeginKm() == null)) {
return "LEFT_BOUNDARY_CENSORED";
}
if (startAligned && !endAligned
&& (card.odometerEndKm() == null || iw.odometerEndKm() == null)) {
return "RIGHT_BOUNDARY_CENSORED";
}
}
return "COMPATIBLE_INTERVAL_BOUNDARIES";
}
private long crossRepresentationMatchScore(
DriverWorkingTimeVehicleUsageInterval database,
DriverWorkingTimeVehicleUsageInterval fileSession
) {
long start = absoluteDeltaSeconds(database.startedAt(), fileSession.startedAt());
long end = absoluteDeltaSeconds(database.endedAt(), fileSession.endedAt());
boolean startAligned = start <= MATCH_TOLERANCE_SECONDS;
boolean endAligned = end <= MATCH_TOLERANCE_SECONDS;
if (startAligned && endAligned) {
return start + end;
}
// Prefer exact/near-exact candidates over a boundary-censored candidate.
return 1_000_000_000L + (startAligned ? start : endAligned ? end : start + end);
}
private long matchScore(DriverWorkingTimeVehicleUsageInterval card, DriverWorkingTimeVehicleUsageInterval iw) {
Long start = absoluteDeltaSeconds(card.startedAt(), iw.startedAt());
Long end = absoluteDeltaSeconds(card.endedAt(), iw.endedAt());
@ -431,7 +748,7 @@ public class RuntimeVehicleUsageReconciliationService {
}
String id = interval.intervalId();
if (id != null && !id.isBlank()) {
return id;
return String.valueOf(interval.sessionId()) + "|" + id;
}
return interval.driverKey() + "|" + interval.registrationKey() + "|" + interval.vehicleKey()
+ "|" + interval.startedAt() + "|" + interval.endedAt();
@ -466,6 +783,12 @@ public class RuntimeVehicleUsageReconciliationService {
return null;
}
private record CrossRepresentationStepResult(
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals
) {
}
private record ReconciliationStepResult(
List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals,
List<DriverWorkingTimeVehicleUsageInterval> suppressedIntervals

View File

@ -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();
}
}

View File

@ -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)

View File

@ -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()
))
);
}
}

View File

@ -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);

View File

@ -0,0 +1,199 @@
package at.procon.eventhub.processing.eventprocessing.module;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import at.procon.eventhub.processing.eventprocessing.vehicleusage.RuntimeVehicleUsageReconciliationResult;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class DriverVehicleUsageMergeModuleTest {
private static final String DRIVER_KEY = "13:DF000358328840";
private static final String REGISTRATION_KEY = "13:RO BS 2219";
private static final UUID FILE_SESSION_ID = UUID.fromString("b7aa193e-d891-4b79-9d55-973441ad4f87");
private static final UUID DATABASE_SESSION_ID = new UUID(0L, 0L);
@Test
void mergesBoundaryCensoredFileAndDatabaseCvuAfterReconciliation() {
DriverWorkingTimeVehicleUsageInterval fileSession = interval(
FILE_SESSION_ID,
"CVU_MERGED|file",
"CVU-159",
"CVU-161",
"2026-03-31T00:00:00Z",
"2026-04-02T12:27:15Z",
null,
173268L,
null,
List.of("CVU-159", "CVU-160", "CVU-161")
);
DriverWorkingTimeVehicleUsageInterval database = interval(
DATABASE_SESSION_ID,
"CVU_MERGED|database",
"4200833",
"4200834",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
172945L,
173268L,
"XLRTEH4300G376073",
List.of("4200833", "4200834")
);
RuntimeProcessingModuleResult result = execute(List.of(fileSession, database));
@SuppressWarnings("unchecked")
List<DriverWorkingTimeVehicleUsageInterval> merged =
(List<DriverWorkingTimeVehicleUsageInterval>) result.output();
assertThat(merged).hasSize(1);
DriverWorkingTimeVehicleUsageInterval effective = merged.getFirst();
assertThat(effective.startedAt()).isEqualTo(OffsetDateTime.parse("2026-03-31T00:00:00Z"));
assertThat(effective.endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T12:27:15Z"));
assertThat(effective.sessionId()).isEqualTo(DATABASE_SESSION_ID);
assertThat(effective.intervalId()).isEqualTo("CVU_MERGED|database");
assertThat(effective.vehicleKey()).isEqualTo("XLRTEH4300G376073");
assertThat(effective.odometerBeginKm()).isNull();
assertThat(effective.odometerEndKm()).isEqualTo(173268L);
assertThat(effective.sourceIntervalIds()).contains(
"CVU_MERGED|file",
"CVU_MERGED|database",
"CVU-159",
"CVU-160",
"CVU-161",
"4200833",
"4200834"
);
assertThat(result.metadata()).containsEntry("inputIntervalCount", 2);
assertThat(result.metadata()).containsEntry("mergedIntervalCount", 1);
assertThat(result.metadata()).containsEntry("coalescedIntervalCount", 1);
}
@Test
void doesNotMergeOverlappingIntervalsWithConflictingVin() {
DriverWorkingTimeVehicleUsageInterval left = interval(
DATABASE_SESSION_ID,
"left",
"left",
"left",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
100L,
200L,
"VIN-1",
List.of("left")
);
DriverWorkingTimeVehicleUsageInterval right = interval(
FILE_SESSION_ID,
"right",
"right",
"right",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
100L,
200L,
"VIN-2",
List.of("right")
);
RuntimeProcessingModuleResult result = execute(List.of(left, right));
assertThat((List<?>) result.output()).hasSize(2);
}
@Test
void doesNotMergeAlignedIntervalsWithConflictingBoundaryOdometer() {
DriverWorkingTimeVehicleUsageInterval left = interval(
DATABASE_SESSION_ID,
"left",
"left",
"left",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
100L,
200L,
"VIN-1",
List.of("left")
);
DriverWorkingTimeVehicleUsageInterval right = interval(
FILE_SESSION_ID,
"right",
"right",
"right",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
120L,
200L,
"VIN-1",
List.of("right")
);
RuntimeProcessingModuleResult result = execute(List.of(left, right));
assertThat((List<?>) result.output()).hasSize(2);
}
private RuntimeProcessingModuleResult execute(List<DriverWorkingTimeVehicleUsageInterval> effectiveIntervals) {
RuntimeVehicleUsageReconciliationResult reconciliation = new RuntimeVehicleUsageReconciliationResult(
effectiveIntervals,
effectiveIntervals,
effectiveIntervals,
effectiveIntervals,
List.of(),
List.of(),
List.of(),
List.of()
);
RuntimeProcessingModuleResult previous = new RuntimeProcessingModuleResult(
DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION,
RuntimeProcessingModuleStatus.SUCCESS,
reconciliation,
Map.of(),
List.of()
);
RuntimeProcessingModuleContext context = new RuntimeProcessingModuleContext(
null,
List.of(),
Map.of(),
Map.of(DriverWorkingTimeModuleKeys.VEHICLE_USAGE_RECONCILIATION, previous)
);
return new DriverVehicleUsageMergeModule().execute(context);
}
private DriverWorkingTimeVehicleUsageInterval interval(
UUID sessionId,
String intervalId,
String firstSourceIntervalId,
String lastSourceIntervalId,
String startedAt,
String endedAt,
Long odometerBeginKm,
Long odometerEndKm,
String vehicleKey,
List<String> sourceIds
) {
OffsetDateTime start = OffsetDateTime.parse(startedAt);
OffsetDateTime end = OffsetDateTime.parse(endedAt);
return new DriverWorkingTimeVehicleUsageInterval(
sessionId,
DRIVER_KEY,
intervalId,
firstSourceIntervalId,
lastSourceIntervalId,
start,
end,
start.toEpochSecond(),
end.toEpochSecond(),
end.toEpochSecond() - start.toEpochSecond(),
odometerBeginKm,
odometerEndKm,
REGISTRATION_KEY,
vehicleKey,
"DRIVER_CARD",
sourceIds
);
}
}

View File

@ -94,6 +94,199 @@ class RuntimeVehicleUsageReconciliationServiceTest {
);
}
@Test
void normalizesCvuPerRepresentationBeforeReconcilingDatabaseAndFileSessionIntervals() {
UUID fileSessionId = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID databaseSessionId = new UUID(0L, 0L);
DriverWorkingTimeVehicleUsageInterval fileDayOne = interval(
fileSessionId,
"CVU-159",
"CVU-159",
"CVU-160",
"2026-04-01T00:00:00Z",
"2026-04-01T23:59:59Z",
"DRIVER_CARD",
null,
100L,
250L
);
DriverWorkingTimeVehicleUsageInterval databaseFullInterval = interval(
databaseSessionId,
"TACHOGRAPH:CARD_VEHICLES_USED:4200833",
"4200833",
"4200834",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
"VIN:WDB9634031L123456",
100L,
400L
);
DriverWorkingTimeVehicleUsageInterval fileDayTwo = interval(
fileSessionId,
"CVU-161",
"CVU-161",
"CVU-161",
"2026-04-02T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
null,
250L,
400L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(
fileDayOne,
databaseFullInterval,
fileDayTwo
));
assertThat(result.normalizedCardVehicleUsedIntervals()).hasSize(2);
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(1);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(1);
assertThat(result.suppressedSecondaryIntervals()).hasSize(1);
DriverWorkingTimeVehicleUsageInterval effective = result.effectiveVehicleUsageIntervals().getFirst();
assertThat(effective.sessionId()).isEqualTo(databaseSessionId);
assertThat(effective.intervalId()).isEqualTo("TACHOGRAPH:CARD_VEHICLES_USED:4200833");
assertThat(effective.startedAt()).isEqualTo(OffsetDateTime.parse("2026-04-01T00:00:00Z"));
assertThat(effective.endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T12:27:15Z"));
assertThat(effective.vehicleKey()).isEqualTo("VIN:WDB9634031L123456");
assertThat(effective.sourceIntervalIds()).contains("CVU-159", "CVU-160", "CVU-161", "4200833", "4200834");
assertThat(result.vehicleUsageReconciliationDecisions())
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.contains(
RuntimeVehicleUsageReconciliationService.RULE_CVU_MIDNIGHT_CONTINUATION,
RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT
);
}
@Test
void reconcilesLeftBoundaryCensoredFileSessionCvuWithDatabaseInterval() {
UUID fileSessionId = UUID.fromString("44444444-4444-4444-4444-444444444444");
UUID databaseSessionId = new UUID(0L, 0L);
DriverWorkingTimeVehicleUsageInterval fileSession = interval(
fileSessionId,
"CVU_MERGED|driver|vehicle|2026-03-31T00:00Z|2026-04-02T12:27:15Z",
"CVU-159",
"CVU-161",
"2026-03-31T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
null,
null,
173268L
);
DriverWorkingTimeVehicleUsageInterval database = interval(
databaseSessionId,
"TACHOGRAPH:CARD_VEHICLES_USED:4200833",
"4200833",
"4200834",
"2026-04-01T00:00:00Z",
"2026-04-02T12:27:15Z",
"DRIVER_CARD",
"VIN:XLRTEH4300G376073",
172945L,
173268L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(fileSession, database));
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(1);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(1);
assertThat(result.suppressedSecondaryIntervals()).hasSize(1);
DriverWorkingTimeVehicleUsageInterval effective = result.effectiveVehicleUsageIntervals().getFirst();
assertThat(effective.sessionId()).isEqualTo(databaseSessionId);
assertThat(effective.intervalId()).isEqualTo("TACHOGRAPH:CARD_VEHICLES_USED:4200833");
assertThat(effective.startedAt()).isEqualTo(OffsetDateTime.parse("2026-03-31T00:00:00Z"));
assertThat(effective.endedAt()).isEqualTo(OffsetDateTime.parse("2026-04-02T12:27:15Z"));
assertThat(effective.odometerBeginKm()).isNull();
assertThat(effective.odometerEndKm()).isEqualTo(173268L);
assertThat(effective.vehicleKey()).isEqualTo("VIN:XLRTEH4300G376073");
assertThat(effective.sourceIntervalIds()).contains("CVU-159", "CVU-161", "4200833", "4200834");
assertThat(result.vehicleUsageReconciliationDecisions())
.filteredOn(decision -> RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT
.equals(decision.ruleId()))
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::equivalenceType)
.containsExactly("LEFT_BOUNDARY_CENSORED");
}
@Test
void doesNotReconcileDifferentKnownStartsOnlyBecauseEndsMatch() {
DriverWorkingTimeVehicleUsageInterval database = interval(
new UUID(0L, 0L),
"TACHOGRAPH:CARD_VEHICLES_USED:10",
"10",
"11",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
"VIN:WDB9634031L123456",
100L,
200L
);
DriverWorkingTimeVehicleUsageInterval file = interval(
UUID.fromString("55555555-5555-5555-5555-555555555555"),
"CVU-10",
"CVU-10",
"CVU-10",
"2026-04-01T09:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
null,
120L,
200L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(database, file));
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(2);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(2);
assertThat(result.vehicleUsageReconciliationDecisions())
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.doesNotContain(RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT);
}
@Test
void keepsCrossRepresentationCvuIntervalsSeparateWhenOdometerBoundariesConflict() {
DriverWorkingTimeVehicleUsageInterval database = interval(
new UUID(0L, 0L),
"TACHOGRAPH:CARD_VEHICLES_USED:1",
"1",
"2",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
"VIN:WDB9634031L123456",
100L,
200L
);
DriverWorkingTimeVehicleUsageInterval file = interval(
UUID.fromString("33333333-3333-3333-3333-333333333333"),
"CVU-1",
"CVU-1",
"CVU-1",
"2026-04-01T08:00:00Z",
"2026-04-01T12:00:00Z",
"DRIVER_CARD",
null,
100L,
250L
);
RuntimeVehicleUsageReconciliationResult result = service.reconcile(List.of(database, file));
assertThat(result.representationReconciledCardVehicleUsedIntervals()).hasSize(2);
assertThat(result.effectiveVehicleUsageIntervals()).hasSize(2);
assertThat(result.vehicleUsageReconciliationDecisions())
.extracting(RuntimeVehicleUsageReconciliationDecisionDto::ruleId)
.doesNotContain(RuntimeVehicleUsageReconciliationService.RULE_CVU_CROSS_REPRESENTATION_EQUIVALENT);
}
@Test
void keepsCardVehiclesUsedAsFallbackWhenIwCycleIsMissing() {
DriverWorkingTimeVehicleUsageInterval card = interval(
@ -137,11 +330,37 @@ class RuntimeVehicleUsageReconciliationServiceTest {
String start,
String end,
String sourceKind
) {
return interval(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
intervalId,
firstSourceIntervalId,
lastSourceIntervalId,
start,
end,
sourceKind,
"VIN:WDB9634031L123456",
null,
null
);
}
private DriverWorkingTimeVehicleUsageInterval interval(
UUID sessionId,
String intervalId,
String firstSourceIntervalId,
String lastSourceIntervalId,
String start,
String end,
String sourceKind,
String vehicleKey,
Long odometerBeginKm,
Long odometerEndKm
) {
OffsetDateTime startedAt = OffsetDateTime.parse(start);
OffsetDateTime endedAt = OffsetDateTime.parse(end);
return new DriverWorkingTimeVehicleUsageInterval(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
sessionId,
"1:driver",
intervalId,
firstSourceIntervalId,
@ -151,10 +370,10 @@ class RuntimeVehicleUsageReconciliationServiceTest {
startedAt.toEpochSecond(),
endedAt.toEpochSecond(),
endedAt.toEpochSecond() - startedAt.toEpochSecond(),
null,
null,
odometerBeginKm,
odometerEndKm,
"1:LL-158TE",
"VIN:WDB9634031L123456",
vehicleKey,
sourceKind,
List.of(firstSourceIntervalId, lastSourceIntervalId)
);