Compare commits

...

5 Commits

Author SHA1 Message Date
trifonovt 9ca8c88f91 Centralize runtime country code normalization 2026-06-18 11:43:07 +02:00
trifonovt 4caadf1270 Classify home-to-home country trips 2026-06-17 13:01:46 +02:00
trifonovt 7584bb8578 Harden Nominatim country resolution fallback 2026-06-17 12:20:56 +02:00
trifonovt c91d3cc1c4 Add country trip segmentation runtime module 2026-06-17 10:48:02 +02:00
trifonovt 5a10558612 Add NDI home classification runtime pipeline 2026-06-17 09:13:31 +02:00
52 changed files with 6344 additions and 44 deletions

View File

@ -0,0 +1,56 @@
# Country-code normalization patch
## Problem found in `response 202606171240 session D c home.json`
The response mixed three country identifier systems:
- tachograph numeric nation codes, for example `1`, `12`, `13`;
- tachograph alphabetic nation codes, for example `A`, `CZ`, `D`;
- ISO 3166-1 alpha-2 codes returned by Nominatim, for example `AT`, `CZ`, `DE`.
The projection contained 94 numeric `country` values, 33 numeric `countryFrom`
values, and 33 numeric `countryTo` values. The flat and nested trip segments also
contained numeric country identifiers.
This was not only a presentation problem. A tachograph value such as `13` and a
Nominatim value such as `DE` were considered different. The supplied response
contained 15 false reverse-geocoded country changes of the form `13 -> DE` or
`12 -> CZ`, creating unnecessary country segments.
## Canonical representation
All runtime country fields are now exposed and compared as ISO 3166-1 alpha-2.
Examples:
| Tachograph numeric | Tachograph alphabetic | Canonical ISO alpha-2 |
|---:|---|---|
| `1` | `A` | `AT` |
| `12` | `CZ` | `CZ` |
| `13` | `D` | `DE` |
Numeric and alphabetic tachograph values are resolved through the existing
`TachographNationRegistry` and then converted to ISO alpha-2. Nominatim results
are validated and normalized as ISO values.
## Processing changes
Normalization is applied before country comparison and at the main presentation
boundaries:
- normalized EventHub support evidence;
- source-neutral runtime support events;
- tachograph file-session support-event adapters;
- legacy tachograph Esper result conversion;
- `projection.supportGeoEvents`;
- flat country-trip segments;
- country segments nested under HOME-to-HOME trips;
- Nominatim country-code parsing.
The original source payload remains available in raw event attributes where the
numeric tachograph values are needed for diagnostics.
## Expected result
An explicit border crossing `1 -> 13` is represented as `AT -> DE`. A subsequent
Nominatim result `DE` no longer creates a false `13 -> DE` transition. The trip
continues in the same canonical `DE` country state until a genuine country change.

View File

@ -0,0 +1,52 @@
# HOME-to-HOME classified trip patch
## Definition
A complete trip is created from two consecutive NDI classifications with status `HOME`.
- Active trip start: `startHomeClassification.evidence.endedAt`
- Active trip end: `endHomeClassification.evidence.startedAt`
- Boundary evidence: both complete HOME classification objects
- Contained evidence: every `NOT_HOME` classification fully contained in the active trip window
- Country segmentation: calculated separately inside each completed trip
A middle HOME classification is shared: it closes the preceding trip and starts the following trip.
Classifications before the first HOME and after the last HOME are not assigned to a completed trip. Their count is exposed as `unassignedNonHomeClassificationCount`.
## Result additions
`DriverCountryTripSegmentationResult` now contains:
- `tripCount`
- `unassignedNonHomeClassificationCount`
- `trips`
Each `DriverClassifiedTrip` contains:
- `tripId`
- `startedAt`, `endedAt`, `durationSeconds`
- `startHomeClassification`
- `endHomeClassification`
- `containedNonHomeClassifications`
- `drivingIntervalCount`
- `countrySegmentCount`
- `countrySegments`
Each `DriverCountryTripSegment` now contains:
- `tripId`
- `countryCode`
The former flat `segments` collection remains available and is the concatenation of all per-trip segments. Compatibility constructors are retained for Java callers using the former record signatures.
## Pipeline order
`country-trip-segmentation` now explicitly depends on:
```text
support-evidence-normalization
ndi-home-classification
```
This guarantees that the trip boundaries are available before country segmentation starts.

View File

@ -0,0 +1,208 @@
# NDI HOME / NOT_HOME classification and country trip segmentation
This patch implements the HOME / NOT_HOME classification and the country-trip segmentation described in `docs/ndi_home_classification_en.md`. It reuses the existing driver-working-time pipeline and adds configurable Nominatim reverse geocoding only where source country evidence is missing.
## Public processing plan
Use:
```text
driver-home-classification-v1
```
The dedicated plan delegates to the shared `driver-working-time-v1` pipeline and explicitly inserts:
```text
support-evidence-normalization
-> ndi-home-classification
-> country-trip-segmentation
-> driving-derived-projections
```
The normal `driver-working-time-v1` plan keeps both modules optional. They can also be requested explicitly as `ndi-home-classification` and `country-trip-segmentation`.
## Reused projection structures
`DriverWorkingTimeReusableProjectionBuilder.buildAllNonDrivingIntervalCoverage(...)` runs the existing Esper interruption/card-absence/GNSS enrichment pipeline with a zero rest-candidate threshold. It creates enriched evidence for every positive non-driving interruption without changing the legacy daily/weekly-rest threshold or outputs.
The implementation reuses `DriverWorkingTimeRestCoverageInterval` as the enriched NDI evidence model. It provides:
- previous and next driving/vehicle identities;
- NDI start, end, and duration;
- card-absence duration and percentage;
- begin/end boundary GNSS evidence;
- boundary odometer and movement evidence.
## HOME / NOT_HOME classification
The rules are evaluated in the document order:
1. previous and next vehicles differ -> `HOME`;
2. card absent for more than 80% -> `HOME`;
3. NDI longer than 24 hours -> `HOME`;
4. no position: NDI longer than 7.5 hours -> `HOME`, otherwise `NOT_HOME`;
5. positioned long NDI in a company or driver home cluster -> `HOME`;
6. positioned long NDI outside those clusters -> `NOT_HOME`;
7. remaining short NDI -> `NOT_HOME`.
Every classification contains a `DriverNdiHomeClassificationReason`, so the first matching rule remains visible in the API response.
## Location learning and clustering
Only NDIs longer than 7.5 hours with a position are added to the corpus. Position selection uses the existing resolved begin-boundary evidence and falls back to resolved end-boundary evidence.
The in-memory cache:
- accumulates observations across one or more file-session executions;
- deduplicates the same NDI across repeated/overlapping sessions;
- retains source-session provenance;
- stores the driver key on every observation;
- calculates actual-driver and other-driver views per request.
Clustering uses Java DBSCAN with Haversine distance. Defaults are 150 metres and three points. Noise observations remain in the denominator for visit-share calculations but are never home clusters.
## HOME-to-HOME trips and country segmentation
A complete trip is now defined by two consecutive `HOME` classifications:
```text
start HOME NDI --active trip time and contained NOT_HOME NDIs--> end HOME NDI
```
The active trip starts at the end timestamp of the start HOME NDI and ends at the start timestamp of the end HOME NDI. The result retains both HOME classifications as boundary evidence and attaches every `NOT_HOME` classification fully contained between those boundaries. A HOME classification can close one trip and simultaneously become the start boundary of the next trip.
Data before the first HOME classification and after the last HOME classification is not emitted as a complete trip. The number of `NOT_HOME` classifications outside complete trips is returned as `unassignedNonHomeClassificationCount`.
Every `DriverClassifiedTrip` contains:
- deterministic `tripId`;
- active `startedAt`, `endedAt`, and duration;
- `startHomeClassification`;
- `endHomeClassification`;
- `containedNonHomeClassifications`;
- driving-interval count;
- country segments calculated only inside that trip.
`DriverCountryTripSegmentationService` calculates country segments independently for each complete trip. The flat `segments` list remains available for compatibility, but is now the concatenation of all per-trip segments. Every segment contains its owning `tripId` and an explicit `countryCode`.
Evidence precedence is:
1. explicit tachograph border-crossing event (`countryFrom` / `countryTo`);
2. country code already present on a positioned support event;
3. Nominatim reverse lookup for a positioned event without a usable country code.
Country values are normalized to ISO 3166-1 alpha-2 where a mapping is known. Segment boundaries retain their evidence source:
```text
EXPLICIT_BORDER_CROSSING
GNSS_SOURCE_COUNTRY_CHANGE
NOMINATIM_COUNTRY_CHANGE
VEHICLE_CHANGE
FINAL
```
The result includes trip and segment counts, unassigned classification counts, explicit-border counts, remote lookup counts, cache-hit counts, unresolved-coordinate counts, warnings, and OpenStreetMap attribution.
## Nominatim integration
The client uses the reverse endpoint with:
```text
format=jsonv2
zoom=3
addressdetails=1
layer=address
```
Only `address.country_code` is required by the classification/segmentation logic. Failures do not fail the whole processing plan; the coordinate remains unresolved and a diagnostic warning is returned.
Safeguards:
- identifying configurable `User-Agent`;
- optional identifying email;
- shared coordinate cache with TTL and maximum size;
- coordinate quantization for cache reuse;
- one execution-level remote lookup budget;
- fully serialized remote calls;
- configurable minimum interval;
- enforced minimum one-second interval for `nominatim.openstreetmap.org`;
- public OSM endpoint disabled unless deliberately opted in;
- configurable endpoint so a self-hosted or contracted Nominatim service can be substituted without code changes.
### Configuration
```yaml
eventhub:
reverse-geocoding:
enabled: true
provider: NOMINATIM
nominatim:
base-url: https://nominatim.openstreetmap.org
public-service-enabled: false
user-agent: eventhub-tachograph/0.1 (Nominatim reverse geocoding)
email: ""
accept-language: en
connect-timeout: 10s
read-timeout: 20s
minimum-request-interval: 1s
cache-ttl: 30d
cache-max-entries: 100000
coordinate-decimal-places: 4
max-remote-lookups-per-execution: 25
```
Environment variables use the `NOMINATIM_*` names shown in `application.yml`.
For a self-hosted endpoint, set `NOMINATIM_BASE_URL`; `public-service-enabled` is not needed. For deliberately selected, policy-compliant, low-volume use of the donated public endpoint, additionally set:
```text
NOMINATIM_PUBLIC_SERVICE_ENABLED=true
NOMINATIM_USER_AGENT=<application/version and contact identifier>
NOMINATIM_EMAIL=<contact email when appropriate>
```
Production or recurring tachograph batch processing should use a self-hosted instance or a provider whose terms cover the expected workload. Coordinates may reveal vehicle or driver movements; do not send confidential or personal-location data to a public endpoint without an appropriate legal and privacy basis.
## File-session learning scope
The dedicated plan defaults `ndiLearnAllFileSessionDrivers` to `true`. For a request with explicit canonical driver keys, it internally loads all drivers from selected file sessions for location learning and filters the response back to the originally requested drivers.
The scope is not broadened when the source is mixed/database-only, the option is disabled, or the result cannot safely be filtered by canonical driver key.
## Response extensions
Each driver partition can contain:
```text
ndiHomeClassification
countryTripSegmentation
```
The fields are omitted when their optional modules were not executed, preserving the existing JSON shape for normal `driver-working-time-v1` calls.
### Trip response shape
```json
{
"countryTripSegmentation": {
"tripCount": 1,
"unassignedNonHomeClassificationCount": 0,
"trips": [
{
"tripId": "DRIVER_TRIP|...",
"startedAt": "2026-05-01T08:00:00Z",
"endedAt": "2026-05-03T18:00:00Z",
"startHomeClassification": { "intervalId": "NDI-START", "status": "HOME" },
"endHomeClassification": { "intervalId": "NDI-END", "status": "HOME" },
"containedNonHomeClassifications": [
{ "intervalId": "NDI-AWAY-REST", "status": "NOT_HOME" }
],
"countrySegments": [
{ "tripId": "DRIVER_TRIP|...", "countryCode": "AT" },
{ "tripId": "DRIVER_TRIP|...", "countryCode": "DE" }
]
}
]
}
}
```

View File

@ -0,0 +1,558 @@
# 1. Terminology and source data
The algorithm uses data from:
- `M_`: vehicle-unit tachograph data, especially GNSS positions.
- `C_`: driver-card data, especially activities and card insertion/removal events.
The main objects are:
## DI — Driving Interval
A continuous interval in which the driver is driving.
It contains:
- driver
- vehicle
- start and end time
- start and end positions
- optional GNSS trace points
## NDI — Non-Driving Interval
The gap between two consecutive driving intervals.
It contains:
- driver
- vehicle before the interval
- vehicle after the interval
- start and end time
- inferred position
- card-removal interval
- location cluster
- classification: `HOME` or `NOT_HOME`
In this specification, `HOME` does not necessarily mean the drivers private residence. It means that the interruption is treated as a home/base interruption. It can also represent:
- a company depot,
- a vehicle change,
- a long card-removal period,
- or simply a rest longer than 24 hours.
# 2. How an NDI is built
Driving intervals are grouped by driver and ordered chronologically.
For every pair of consecutive driving intervals:
```text
previous driving interval
non-driving interval
next driving interval
```
The NDI is created as follows:
```text
NDI.start = previous DI.end
NDI.end = next DI.start
```
The vehicles are:
```text
NDI.vehicleStart = previous DI.vehicleId
NDI.vehicleEnd = next DI.vehicleId
```
The position is selected using:
```text
previous DI end position
otherwise
next DI start position
otherwise
no position
```
So the exact position rule is:
```text
NDI.pos = previous.posEnd ?? next.posStart
```
The driver-card events between the two driving intervals are used to find the card-removal interval.
## Important limitations
The algorithm creates NDIs only between consecutive driving intervals. It does not create:
- an NDI before the first driving interval,
- an NDI after the final driving interval,
- or an NDI when only card/activity data exists without surrounding driving intervals.
# 3. How home locations are learned
Only NDIs satisfying both conditions are used for location learning:
```text
duration > 7.5 hours
position is known
```
Exactly 7.5 hours does not qualify because the comparison is strictly `>`.
## 3.1 Location clustering
The positions are clustered globally with DBSCAN:
| Parameter | Value |
|---|---:|
| Maximum cluster distance | 150 metres |
| Minimum points | 3 |
| Distance calculation | Haversine/PostGIS |
| Unclustered points | `NOISE` |
All drivers qualifying NDIs appear to be clustered together in one global clustering operation.
## 3.2 Company-home locations
A cluster becomes a company-home location, normally interpreted as a depot, when:
```text
visits to cluster / all long positioned NDIs > 25%
```
The denominator is all NDIs in the complete dataset that:
- are longer than 7.5 hours, and
- have a position.
Noise points are excluded as possible company-home clusters.
## 3.3 Driver-home locations
For each driver, a cluster becomes a private driver-home location when:
```text
driver's visits to cluster /
driver's long positioned NDIs > 25%
```
Additionally:
```text
cluster must not already be a company-home cluster
```
Therefore, a location used frequently by the whole company is classified as a company depot rather than a private driver home.
## Exact threshold behaviour
The comparisons are strict:
- exactly 25% is not enough;
- the share must be greater than 25%.
# 4. Rules for determining whether an NDI is HOME or NOT_HOME
The rules are evaluated in a fixed priority order. The first matching rule wins.
## Decision table
| Priority | Condition | Result |
|---:|---|---|
| A | Vehicle before NDI differs from vehicle after NDI | `HOME` |
| B | Card is removed for more than 80% of the NDI | `HOME` |
| C | NDI duration is more than 24 hours | `HOME` |
| D1 | Position unknown and duration more than 7.5 hours | `HOME` |
| D2 | Position unknown and duration no more than 7.5 hours | `NOT_HOME` |
| E1 | Position known, duration more than 7.5 hours, and position belongs to company-home or driver-home cluster | `HOME` |
| E2 | Position known, duration more than 7.5 hours, but position is not a recognised home cluster | `NOT_HOME` |
| F | All remaining short NDIs | `NOT_HOME` |
## Rule A: Change of vehicle
```text
vehicleStart != vehicleEnd → HOME
```
When the next driving interval starts in another vehicle, the NDI is always treated as `HOME`.
This rule has the highest priority. It applies even when:
- the NDI is short,
- the position is known to be away from home,
- or the driver card remained inserted for much of the interval.
This is therefore a technical trip-separation rule rather than proof that the driver was physically at home.
## Rule B: Card removed for more than 80%
```text
cardOut duration > 80% of NDI duration → HOME
```
Exactly 80% is not sufficient.
Example:
```text
NDI duration: 10 hours
Card removed: 8 hours
Result: NOT enough for Rule B
Card removed: 8 hours 1 minute
Result: HOME
```
The data model contains only one `cardOut` interval. It is not defined how several card-removal periods inside one NDI should be combined.
## Rule C: NDI longer than 24 hours
```text
NDI duration > 24 hours → HOME
```
Exactly 24 hours is not sufficient.
This rule overrides the position logic. Even when the driver remains at an unrecognised remote location, an NDI longer than 24 hours is classified as `HOME`.
## Rule D: No known position
When the position cannot be determined:
```text
duration > 7.5 hours → HOME
duration <= 7.5 hours → NOT_HOME
```
This is a fallback assumption. A long rest without location evidence is assumed to be home.
## Rule E: Long NDI with a known position
For an NDI longer than 7.5 hours:
```text
position in company-home cluster → HOME
position in driver's home cluster → HOME
otherwise → NOT_HOME
```
The specification describes the final case as an overnight stay in the vehicle, although the data itself only establishes that the rest occurred away from a recognised home location.
## Rule F: Short NDI
A short NDI defaults to:
```text
NOT_HOME
```
However, a short NDI can still be classified as `HOME` by the earlier rules:
- vehicle changed, or
- card removed for more than 80% of the interval.
# 5. Compact HOME/NOT_HOME decision flow
```text
Did the vehicle change?
├─ Yes → HOME
└─ No
├─ Was the card removed for more than 80% of the NDI?
│ ├─ Yes → HOME
│ └─ No
├─ Is the NDI longer than 24 hours?
│ ├─ Yes → HOME
│ └─ No
├─ Is the position missing?
│ ├─ Yes and duration > 7.5 h → HOME
│ ├─ Yes and duration <= 7.5 h → NOT_HOME
│ └─ No
├─ Is the NDI longer than 7.5 hours?
│ ├─ No → NOT_HOME
│ └─ Yes
│ ├─ Company-home cluster → HOME
│ ├─ Driver-home cluster → HOME
│ └─ Other location/noise → NOT_HOME
```
# 6. How trip segments are currently determined
The code does not create a trip object. It creates `TripSegment` records based on country changes.
Each segment contains:
- driver
- vehicle
- start and end time
- country before and after the boundary
- start and end positions
The algorithm works as follows:
1. Take all driving intervals belonging to one driver.
2. Sort them chronologically.
3. Start at the beginning of the first driving interval.
4. Determine the country of the first position.
5. Scan all GNSS trace points in all driving intervals.
6. Reverse-geocode every trace point to a country.
7. When the country changes:
- close the current segment at that trace-point timestamp;
- store the old and new country;
- begin a new segment at the same trace point.
8. After all trace points, close the final segment at the end of the final driving interval.
Example:
```text
08:00 Driving starts in Austria
10:15 GNSS position changes from Austria to Germany
14:00 GNSS position changes from Germany to France
18:00 Final driving interval ends
```
Segments produced:
```text
Segment 1: 08:0010:15, Austria → Germany
Segment 2: 10:1514:00, Germany → France
Segment 3: 14:0018:00, France → France
```
The final segment has the same `countryFrom` and `countryTo` because no further border was crossed.
# 7. What currently determines a “trip”
Strictly speaking, the specification does not determine individual trips.
For each driver, the segment-building function starts at:
```text
first driving interval start
```
and continues until:
```text
last driving interval end
```
It splits this complete period only at country changes.
This means that if the input covers an entire month, the algorithm may effectively process the whole month as one continuous sequence of country segments—even when the driver returned home several times.
The `HOME` and `NOT_HOME` classifications are not passed into `buildTripSegments()`. In fact, trip segments are built before the NDIs are classified:
```text
build NDIs
build trip segments
cluster NDIs
determine home locations
classify NDIs
```
Consequently:
```text
HOME NDI does not end a trip
NOT_HOME NDI does not explicitly continue a trip
```
The two parts of the algorithm are currently disconnected.
# 8. Likely intended trip definition
Based on the purpose of the HOME/NOT_HOME classification, the intended definition is most likely:
> A trip is a maximal chronological sequence of driving intervals separated only by `NOT_HOME` NDIs. A `HOME` NDI closes the current trip and separates it from the next trip.
That would produce the following rules:
## Trip start
A trip begins:
- at the first available driving interval, or
- at the first driving interval following a `HOME` NDI.
## Trip continuation
The same trip continues across an NDI when:
```text
NDI.status = NOT_HOME
```
This includes:
- short breaks,
- overnight rests away from recognised home locations,
- rest in the vehicle,
- long rests at remote locations up to 24 hours.
## Trip end
A trip ends at the end of the driving interval preceding an NDI when:
```text
NDI.status = HOME
```
The next driving interval begins a new trip.
## Country segmentation inside a trip
After trips are established, each trip is divided into country segments at:
- an explicit tachograph border-crossing event, or
- a reliable country change inferred from GNSS positions.
The logical hierarchy should therefore be:
```text
Driver timeline
└─ Trip
├─ Country segment 1
├─ Country segment 2
└─ Country segment 3
```
Not:
```text
Driver timeline
└─ Country segments without trip boundaries
```
# 9. Recommended trip-building algorithm
A consistent implementation would be:
```text
1. Build and sort all driving intervals per driver.
2. Build the NDI between every two consecutive driving intervals.
3. Determine location clusters.
4. Classify every NDI as HOME or NOT_HOME.
5. Build trips:
- start with the first DI;
- append NOT_HOME NDI and the following DI to the current trip;
- when a HOME NDI occurs, close the current trip;
- start a new trip with the next DI.
6. Split every resulting trip at country-border crossings.
```
Pseudocode:
```text
currentTrip = new Trip(firstDI)
for every NDI between prevDI and nextDI:
if NDI.status == NOT_HOME:
currentTrip.add(NDI)
currentTrip.add(nextDI)
else:
currentTrip.end = prevDI.end
save(currentTrip)
currentTrip = new Trip(nextDI)
save(currentTrip)
```
Then:
```text
for every trip:
trip.segments = splitAtBorderCrossings(trip)
```
# 10. Issues and ambiguities in the current rules
## Explicit border-crossing events are mentioned but not used
The comment states that a border crossing can come from:
- an explicit Smart Tachograph v2 event, or
- a GNSS-derived country change.
However, the implementation scans only `gnssTrace`. There is no processing of explicit border-crossing events.
## Vehicle identity can be incorrect for a segment
A segment may span several driving intervals and possibly several vehicles. Nevertheless, the segment stores only one `vehicleId`:
- the vehicle active at the border crossing, or
- the vehicle of the final DI for the final segment.
If a vehicle changes without a country crossing, the segment can contain activity from multiple vehicles but retain only the last vehicle ID.
## HOME does not currently split segments or trips
A driver can:
1. drive,
2. return home,
3. remain home for two days,
4. begin a new journey,
and the current segment builder can still represent both journeys as one continuous segment if no country changes occur.
## Position selection may hide conflicting positions
The NDI position always prefers the previous DIs end position:
```text
previous.posEnd ?? next.posStart
```
When both positions exist but differ substantially, the inconsistency is ignored.
## Long unknown-location intervals are assumed HOME
An NDI longer than 7.5 hours without a position is automatically `HOME`. This can incorrectly classify an overnight stay abroad as home when GNSS data is missing.
## All rests longer than 24 hours are HOME
A driver can remain at a foreign parking place for more than 24 hours, but the rule still returns `HOME`. This may be intentional as a trip-reset rule, but it is not reliable as a physical-home determination.
## Global company-home calculation may be dominated by dataset composition
The company-home denominator includes all qualifying NDIs across all drivers. Results can depend on:
- the selected time period,
- drivers with many records,
- missing GNSS data,
- incomplete driver histories.
# Final interpretation
The document currently provides a valid algorithm for:
- constructing NDIs between driving intervals,
- learning frequently visited locations,
- classifying each NDI as `HOME` or `NOT_HOME`,
- and splitting driving history at detected country changes.
But it does **not yet provide a complete trip-building algorithm**.
The most consistent interpretation is:
```text
HOME NDI = boundary between two trips
NOT_HOME NDI = interruption inside the same trip
Border crossing = boundary between segments inside one trip
```
That relationship needs to be explicitly implemented because it is not present in the current `run()` or `buildTripSegments()` logic.

View File

@ -33,6 +33,7 @@ public class EventHubProperties {
private final RuntimeProcessing runtimeProcessing = new RuntimeProcessing();
private final TachographFileSession tachographFileSession = new TachographFileSession(runtimeProcessing);
private final EsperPoc esperPoc = new EsperPoc();
private final ReverseGeocoding reverseGeocoding = new ReverseGeocoding();
private final YellowFox yellowFox = new YellowFox();
public Batch getBatch() {
@ -55,10 +56,174 @@ public class EventHubProperties {
return esperPoc;
}
public ReverseGeocoding getReverseGeocoding() {
return reverseGeocoding;
}
public YellowFox getYellowFox() {
return yellowFox;
}
public static class ReverseGeocoding {
private boolean enabled = true;
private String provider = "NOMINATIM";
private final Nominatim nominatim = new Nominatim();
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
if (provider != null && !provider.isBlank()) {
this.provider = provider.trim().toUpperCase(java.util.Locale.ROOT);
}
}
public Nominatim getNominatim() {
return nominatim;
}
}
public static class Nominatim {
private String baseUrl = "https://nominatim.openstreetmap.org";
private boolean publicServiceEnabled = false;
private String userAgent = "eventhub-tachograph/0.1 (Nominatim reverse geocoding)";
private String email;
private String acceptLanguage = "en";
private Duration connectTimeout = Duration.ofSeconds(10);
private Duration readTimeout = Duration.ofSeconds(20);
private Duration minimumRequestInterval = Duration.ofSeconds(1);
private Duration cacheTtl = Duration.ofDays(30);
private int cacheMaxEntries = 100000;
private int coordinateDecimalPlaces = 4;
private int maxRemoteLookupsPerExecution = 25;
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank()) {
return;
}
String normalized = baseUrl.trim();
while (normalized.endsWith("/")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
if (!normalized.isBlank()) {
this.baseUrl = normalized;
}
}
public boolean isPublicServiceEnabled() {
return publicServiceEnabled;
}
public void setPublicServiceEnabled(boolean publicServiceEnabled) {
this.publicServiceEnabled = publicServiceEnabled;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
if (userAgent != null && !userAgent.isBlank()) {
this.userAgent = userAgent.trim();
}
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email == null || email.isBlank() ? null : email.trim();
}
public String getAcceptLanguage() {
return acceptLanguage;
}
public void setAcceptLanguage(String acceptLanguage) {
if (acceptLanguage != null && !acceptLanguage.isBlank()) {
this.acceptLanguage = acceptLanguage.trim();
}
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
if (connectTimeout != null && !connectTimeout.isNegative() && !connectTimeout.isZero()) {
this.connectTimeout = connectTimeout;
}
}
public Duration getReadTimeout() {
return readTimeout;
}
public void setReadTimeout(Duration readTimeout) {
if (readTimeout != null && !readTimeout.isNegative() && !readTimeout.isZero()) {
this.readTimeout = readTimeout;
}
}
public Duration getMinimumRequestInterval() {
return minimumRequestInterval;
}
public void setMinimumRequestInterval(Duration minimumRequestInterval) {
if (minimumRequestInterval != null && !minimumRequestInterval.isNegative()) {
this.minimumRequestInterval = minimumRequestInterval;
}
}
public Duration getCacheTtl() {
return cacheTtl;
}
public void setCacheTtl(Duration cacheTtl) {
if (cacheTtl != null && !cacheTtl.isNegative() && !cacheTtl.isZero()) {
this.cacheTtl = cacheTtl;
}
}
public int getCacheMaxEntries() {
return cacheMaxEntries;
}
public void setCacheMaxEntries(int cacheMaxEntries) {
this.cacheMaxEntries = Math.max(100, cacheMaxEntries);
}
public int getCoordinateDecimalPlaces() {
return coordinateDecimalPlaces;
}
public void setCoordinateDecimalPlaces(int coordinateDecimalPlaces) {
this.coordinateDecimalPlaces = Math.max(0, Math.min(7, coordinateDecimalPlaces));
}
public int getMaxRemoteLookupsPerExecution() {
return maxRemoteLookupsPerExecution;
}
public void setMaxRemoteLookupsPerExecution(int maxRemoteLookupsPerExecution) {
this.maxRemoteLookupsPerExecution = Math.max(0, maxRemoteLookupsPerExecution);
}
}
public static class EsperPoc {
private EsperActivityMergeMode activityMergeMode = EsperActivityMergeMode.JAVA;
private EsperShiftResolutionMode shiftResolutionMode = EsperShiftResolutionMode.JAVA;
@ -381,6 +546,15 @@ public class EventHubProperties {
private int restCandidateGeoLookaheadMinutes = 180;
private int restCandidateGeoStationaryMaxMeters = 500;
private int restCandidateGeoMinorMovementMaxMeters = 2000;
private int ndiLongMinutes = 450;
private int ndiVeryLongMinutes = 1440;
private double ndiCardRemovalPercent = 80.0d;
private double ndiVisitSharePercent = 25.0d;
private int ndiDbscanEpsMeters = 150;
private int ndiDbscanMinPoints = 3;
private Duration ndiLocationCacheTtl = Duration.ofHours(4);
private int ndiLocationCacheMaxObservations = 100000;
private String ndiLocationCacheNamespace = "default";
private int mergeGapSeconds = 0;
private int gapDetectionToleranceSeconds = 0;
@ -461,6 +635,83 @@ public class EventHubProperties {
Math.max(this.restCandidateGeoStationaryMaxMeters, restCandidateGeoMinorMovementMaxMeters);
}
public int getNdiLongMinutes() {
return ndiLongMinutes;
}
public void setNdiLongMinutes(int ndiLongMinutes) {
this.ndiLongMinutes = Math.max(1, ndiLongMinutes);
this.ndiVeryLongMinutes = Math.max(this.ndiLongMinutes, this.ndiVeryLongMinutes);
}
public int getNdiVeryLongMinutes() {
return ndiVeryLongMinutes;
}
public void setNdiVeryLongMinutes(int ndiVeryLongMinutes) {
this.ndiVeryLongMinutes = Math.max(this.ndiLongMinutes, ndiVeryLongMinutes);
}
public double getNdiCardRemovalPercent() {
return ndiCardRemovalPercent;
}
public void setNdiCardRemovalPercent(double ndiCardRemovalPercent) {
this.ndiCardRemovalPercent = Math.max(0.0d, Math.min(100.0d, ndiCardRemovalPercent));
}
public double getNdiVisitSharePercent() {
return ndiVisitSharePercent;
}
public void setNdiVisitSharePercent(double ndiVisitSharePercent) {
this.ndiVisitSharePercent = Math.max(0.0d, Math.min(100.0d, ndiVisitSharePercent));
}
public int getNdiDbscanEpsMeters() {
return ndiDbscanEpsMeters;
}
public void setNdiDbscanEpsMeters(int ndiDbscanEpsMeters) {
this.ndiDbscanEpsMeters = Math.max(1, ndiDbscanEpsMeters);
}
public int getNdiDbscanMinPoints() {
return ndiDbscanMinPoints;
}
public void setNdiDbscanMinPoints(int ndiDbscanMinPoints) {
this.ndiDbscanMinPoints = Math.max(1, ndiDbscanMinPoints);
}
public Duration getNdiLocationCacheTtl() {
return ndiLocationCacheTtl;
}
public void setNdiLocationCacheTtl(Duration ndiLocationCacheTtl) {
if (ndiLocationCacheTtl != null && !ndiLocationCacheTtl.isNegative() && !ndiLocationCacheTtl.isZero()) {
this.ndiLocationCacheTtl = ndiLocationCacheTtl;
}
}
public int getNdiLocationCacheMaxObservations() {
return ndiLocationCacheMaxObservations;
}
public void setNdiLocationCacheMaxObservations(int ndiLocationCacheMaxObservations) {
this.ndiLocationCacheMaxObservations = Math.max(100, ndiLocationCacheMaxObservations);
}
public String getNdiLocationCacheNamespace() {
return ndiLocationCacheNamespace;
}
public void setNdiLocationCacheNamespace(String ndiLocationCacheNamespace) {
if (ndiLocationCacheNamespace != null && !ndiLocationCacheNamespace.isBlank()) {
this.ndiLocationCacheNamespace = ndiLocationCacheNamespace.trim();
}
}
public int getMergeGapSeconds() {
return mergeGapSeconds;
}

View File

@ -0,0 +1,39 @@
package at.procon.eventhub.geocoding.model;
import java.math.BigDecimal;
public record GeoCountryResolution(
GeoCountryResolutionStatus status,
BigDecimal latitude,
BigDecimal longitude,
String countryCode,
String countryName,
String displayName,
String provider,
String attribution,
boolean cacheHit,
boolean remoteRequestPerformed,
String errorMessage
) {
public boolean resolved() {
return status == GeoCountryResolutionStatus.RESOLVED
&& countryCode != null
&& !countryCode.isBlank();
}
public GeoCountryResolution asCacheHit(BigDecimal requestedLatitude, BigDecimal requestedLongitude) {
return new GeoCountryResolution(
status,
requestedLatitude,
requestedLongitude,
countryCode,
countryName,
displayName,
provider,
attribution,
true,
false,
errorMessage
);
}
}

View File

@ -0,0 +1,9 @@
package at.procon.eventhub.geocoding.model;
public enum GeoCountryResolutionStatus {
RESOLVED,
NOT_FOUND,
DISABLED,
REMOTE_LOOKUP_NOT_ALLOWED,
ERROR
}

View File

@ -0,0 +1,19 @@
package at.procon.eventhub.geocoding.service;
import at.procon.eventhub.geocoding.model.GeoCountryResolution;
import java.math.BigDecimal;
/**
* Resolves a WGS84 coordinate to an ISO country code.
*
* <p>The {@code allowRemoteLookup} flag lets a caller enforce a per-execution
* request budget while still benefiting from shared cached results.</p>
*/
public interface GeoCountryResolver {
GeoCountryResolution resolve(
BigDecimal latitude,
BigDecimal longitude,
boolean allowRemoteLookup
);
}

View File

@ -0,0 +1,505 @@
package at.procon.eventhub.geocoding.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.geocoding.model.GeoCountryResolution;
import at.procon.eventhub.geocoding.model.GeoCountryResolutionStatus;
import at.procon.eventhub.reference.CountryCodeNormalizer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* Blocking Nominatim reverse-geocoding client with a shared coordinate cache
* and a process-wide request gate.
*
* <p>The public OpenStreetMap Nominatim service permits at most one request per
* second. The default configuration therefore uses a one-second minimum
* interval. Self-hosted Nominatim installations may configure another value.</p>
*/
@Component
public class NominatimGeoCountryResolver implements GeoCountryResolver {
private static final Logger LOG = LoggerFactory.getLogger(NominatimGeoCountryResolver.class);
private static final String PROVIDER = "NOMINATIM";
private final EventHubProperties properties;
private final ObjectMapper objectMapper;
private final HttpClient httpClient;
private final Clock clock;
private final Map<CoordinateKey, CacheEntry> cache = new ConcurrentHashMap<>();
private final AtomicLong nextRemoteRequestNanos = new AtomicLong(0L);
private final Object requestGate = new Object();
@Autowired
public NominatimGeoCountryResolver(
EventHubProperties properties,
ObjectMapper objectMapper
) {
this(
properties,
objectMapper,
HttpClient.newBuilder()
.connectTimeout(properties.getReverseGeocoding().getNominatim().getConnectTimeout())
.build(),
Clock.systemUTC()
);
}
NominatimGeoCountryResolver(
EventHubProperties properties,
ObjectMapper objectMapper,
HttpClient httpClient,
Clock clock
) {
this.properties = Objects.requireNonNull(properties, "properties must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.httpClient = Objects.requireNonNull(httpClient, "httpClient must not be null");
this.clock = Objects.requireNonNull(clock, "clock must not be null");
}
@Override
public GeoCountryResolution resolve(
BigDecimal latitude,
BigDecimal longitude,
boolean allowRemoteLookup
) {
if (!validCoordinate(latitude, longitude)) {
return result(
GeoCountryResolutionStatus.ERROR,
latitude,
longitude,
null,
null,
null,
false,
false,
"Latitude/longitude is missing or outside the WGS84 range."
);
}
EventHubProperties.ReverseGeocoding reverseConfig = properties.getReverseGeocoding();
EventHubProperties.Nominatim config = reverseConfig.getNominatim();
CoordinateKey key = CoordinateKey.of(latitude, longitude, config.getCoordinateDecimalPlaces());
CacheEntry cached = cache.get(key);
Instant now = clock.instant();
if (cached != null && cached.expiresAt().isAfter(now)) {
return cached.resolution().asCacheHit(latitude, longitude);
}
if (cached != null) {
cache.remove(key, cached);
}
if (!reverseConfig.isEnabled() || !"NOMINATIM".equalsIgnoreCase(reverseConfig.getProvider())) {
return result(
GeoCountryResolutionStatus.DISABLED,
latitude,
longitude,
null,
null,
null,
false,
false,
"Nominatim reverse geocoding is disabled."
);
}
if (isPublicService(config) && !config.isPublicServiceEnabled()) {
return result(
GeoCountryResolutionStatus.DISABLED,
latitude,
longitude,
null,
null,
null,
false,
false,
"The public nominatim.openstreetmap.org service requires explicit opt-in. "
+ "Set eventhub.reverse-geocoding.nominatim.public-service-enabled=true "
+ "only for policy-compliant low-volume use, or configure another Nominatim endpoint."
);
}
if (!allowRemoteLookup) {
return result(
GeoCountryResolutionStatus.REMOTE_LOOKUP_NOT_ALLOWED,
latitude,
longitude,
null,
null,
null,
false,
false,
"The reverse-geocoding budget for this processing execution was exhausted."
);
}
GeoCountryResolution resolved = resolveRemote(latitude, longitude, config);
if (resolved.status() == GeoCountryResolutionStatus.RESOLVED
|| resolved.status() == GeoCountryResolutionStatus.NOT_FOUND) {
putCache(key, resolved, now.plus(config.getCacheTtl()), config.getCacheMaxEntries());
}
return resolved;
}
private GeoCountryResolution resolveRemote(
BigDecimal latitude,
BigDecimal longitude,
EventHubProperties.Nominatim config
) {
try {
synchronized (requestGate) {
awaitRequestPermit(effectiveMinimumRequestInterval(config));
URI uri = reverseUri(latitude, longitude, config, false);
HttpResponse<String> response = sendRequest(uri, config, true);
if (response.statusCode() == 406) {
URI compatibilityUri = reverseUri(latitude, longitude, config, true);
LOG.info(
"Nominatim endpoint rejected the standard request with HTTP 406 for {}. "
+ "Retrying once with a legacy-compatible request matching JsonNominatimClient.",
uri.getHost()
);
awaitRequestPermit(effectiveMinimumRequestInterval(config));
response = sendCompatibilityRequest(compatibilityUri, config);
}
if (response.statusCode() == 404) {
return result(
GeoCountryResolutionStatus.NOT_FOUND,
latitude,
longitude,
null,
null,
null,
false,
true,
"Nominatim returned no address for the coordinate."
);
}
if (response.statusCode() / 100 != 2) {
String responseSummary = responseBodySummary(response.body());
LOG.warn(
"Nominatim reverse lookup failed for {},{} with HTTP status {} and response body: {}",
latitude,
longitude,
response.statusCode(),
responseSummary
);
return result(
GeoCountryResolutionStatus.ERROR,
latitude,
longitude,
null,
null,
null,
false,
true,
"Nominatim reverse lookup failed with HTTP status " + response.statusCode()
+ (responseSummary == null ? "." : ": " + responseSummary)
);
}
JsonNode root = objectMapper.readTree(response.body());
if (root.hasNonNull("error")) {
return result(
GeoCountryResolutionStatus.NOT_FOUND,
latitude,
longitude,
null,
null,
text(root, "display_name"),
false,
true,
root.get("error").asText()
);
}
JsonNode address = root.path("address");
String countryCode = normalizeCountryCode(text(address, "country_code"));
String countryName = text(address, "country");
if (countryCode == null) {
return result(
GeoCountryResolutionStatus.NOT_FOUND,
latitude,
longitude,
null,
countryName,
text(root, "display_name"),
false,
true,
"Nominatim response did not contain address.country_code."
);
}
return new GeoCountryResolution(
GeoCountryResolutionStatus.RESOLVED,
latitude,
longitude,
countryCode,
countryName,
text(root, "display_name"),
PROVIDER,
text(root, "licence"),
false,
true,
null
);
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return result(
GeoCountryResolutionStatus.ERROR,
latitude,
longitude,
null,
null,
null,
false,
true,
"Nominatim reverse lookup was interrupted."
);
} catch (IOException | RuntimeException ex) {
LOG.warn("Nominatim reverse lookup failed for {},{}: {}", latitude, longitude, ex.getMessage());
return result(
GeoCountryResolutionStatus.ERROR,
latitude,
longitude,
null,
null,
null,
false,
true,
"Nominatim reverse lookup failed: " + ex.getMessage()
);
}
}
private HttpResponse<String> sendRequest(
URI uri,
EventHubProperties.Nominatim config,
boolean includeContentNegotiationHeaders
) throws IOException, InterruptedException {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(uri)
.version(HttpClient.Version.HTTP_1_1)
.timeout(config.getReadTimeout())
.GET();
if (config.getUserAgent() != null && !config.getUserAgent().isBlank()) {
requestBuilder.header("User-Agent", config.getUserAgent());
}
if (includeContentNegotiationHeaders) {
requestBuilder.header("Accept", "application/json");
if (config.getAcceptLanguage() != null && !config.getAcceptLanguage().isBlank()) {
requestBuilder.header("Accept-Language", config.getAcceptLanguage());
}
}
return httpClient.send(
requestBuilder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
);
}
/**
* Sends the same kind of plain HTTP GET used by the previously working
* fr.dudie JsonNominatimClient: no explicit Accept, Accept-Language or
* User-Agent headers and only legacy-compatible reverse parameters.
*/
private HttpResponse<String> sendCompatibilityRequest(
URI uri,
EventHubProperties.Nominatim config
) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(uri)
.version(HttpClient.Version.HTTP_1_1)
.timeout(config.getReadTimeout())
.header("User-Agent", "")
.GET()
.build();
return httpClient.send(
request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
);
}
private String responseBodySummary(String body) {
if (body == null || body.isBlank()) {
return null;
}
String normalized = body.replaceAll("\\s+", " ").trim();
return normalized.length() <= 500 ? normalized : normalized.substring(0, 500) + "...";
}
private URI reverseUri(
BigDecimal latitude,
BigDecimal longitude,
EventHubProperties.Nominatim config,
boolean legacyCompatible
) {
StringBuilder query = new StringBuilder(normalizedBaseUrl(config.getBaseUrl()))
.append("/reverse?format=jsonv2");
// JsonNominatimClient puts the identifying email directly into the URL.
if (config.getEmail() != null && !config.getEmail().isBlank()) {
query.append("&email=").append(url(config.getEmail()));
}
query.append("&lat=").append(url(latitude.toPlainString()))
.append("&lon=").append(url(longitude.toPlainString()))
.append("&zoom=3");
if (!legacyCompatible) {
query.append("&addressdetails=1")
.append("&layer=address");
if (config.getAcceptLanguage() != null && !config.getAcceptLanguage().isBlank()) {
query.append("&accept-language=").append(url(config.getAcceptLanguage()));
}
}
return URI.create(query.toString());
}
private String normalizedBaseUrl(String baseUrl) {
if (baseUrl == null || baseUrl.isBlank()) {
throw new IllegalArgumentException("Nominatim base URL must not be blank.");
}
return baseUrl.endsWith("/")
? baseUrl.substring(0, baseUrl.length() - 1)
: baseUrl;
}
private Duration effectiveMinimumRequestInterval(EventHubProperties.Nominatim config) {
Duration configured = config.getMinimumRequestInterval() == null
? Duration.ZERO
: config.getMinimumRequestInterval();
try {
if (isPublicService(config)
&& configured.compareTo(Duration.ofSeconds(1)) < 0) {
return Duration.ofSeconds(1);
}
} catch (RuntimeException ignored) {
}
return configured;
}
private boolean isPublicService(EventHubProperties.Nominatim config) {
try {
return "nominatim.openstreetmap.org".equalsIgnoreCase(
URI.create(config.getBaseUrl()).getHost()
);
} catch (RuntimeException ignored) {
return false;
}
}
private void awaitRequestPermit(Duration minimumInterval) throws InterruptedException {
long intervalNanos = minimumInterval == null ? 0L : Math.max(0L, minimumInterval.toNanos());
long now = System.nanoTime();
long allowedAt = nextRemoteRequestNanos.get();
long waitNanos = allowedAt - now;
if (waitNanos > 0L) {
long millis = waitNanos / 1_000_000L;
int nanos = (int) (waitNanos % 1_000_000L);
Thread.sleep(millis, nanos);
}
nextRemoteRequestNanos.set(System.nanoTime() + intervalNanos);
}
private void putCache(
CoordinateKey key,
GeoCountryResolution resolution,
Instant expiresAt,
int maxEntries
) {
cache.put(key, new CacheEntry(resolution, expiresAt, clock.instant()));
if (cache.size() <= maxEntries) {
return;
}
Instant now = clock.instant();
cache.entrySet().removeIf(entry -> !entry.getValue().expiresAt().isAfter(now));
while (cache.size() > maxEntries) {
cache.entrySet().stream()
.min(Comparator.comparing(entry -> entry.getValue().createdAt()))
.map(Map.Entry::getKey)
.ifPresent(cache::remove);
}
}
private boolean validCoordinate(BigDecimal latitude, BigDecimal longitude) {
return latitude != null
&& longitude != null
&& latitude.compareTo(BigDecimal.valueOf(-90)) >= 0
&& latitude.compareTo(BigDecimal.valueOf(90)) <= 0
&& longitude.compareTo(BigDecimal.valueOf(-180)) >= 0
&& longitude.compareTo(BigDecimal.valueOf(180)) <= 0;
}
private String normalizeCountryCode(String value) {
return CountryCodeNormalizer.normalizeIso(value);
}
private String text(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) {
return null;
}
String value = node.get(field).asText();
return value == null || value.isBlank() ? null : value.trim();
}
private String url(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private GeoCountryResolution result(
GeoCountryResolutionStatus status,
BigDecimal latitude,
BigDecimal longitude,
String countryCode,
String countryName,
String displayName,
boolean cacheHit,
boolean remoteRequestPerformed,
String errorMessage
) {
return new GeoCountryResolution(
status,
latitude,
longitude,
countryCode,
countryName,
displayName,
PROVIDER,
null,
cacheHit,
remoteRequestPerformed,
errorMessage
);
}
private record CoordinateKey(BigDecimal latitude, BigDecimal longitude) {
static CoordinateKey of(BigDecimal latitude, BigDecimal longitude, int decimalPlaces) {
return new CoordinateKey(
latitude.setScale(decimalPlaces, RoundingMode.HALF_UP),
longitude.setScale(decimalPlaces, RoundingMode.HALF_UP)
);
}
}
private record CacheEntry(
GeoCountryResolution resolution,
Instant expiresAt,
Instant createdAt
) {
}
}

View File

@ -0,0 +1,16 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeRestCoverageInterval;
public record DriverNdiHomeClassification(
String intervalId,
DriverWorkingTimeRestCoverageInterval evidence,
Double latitude,
Double longitude,
String locationSource,
String clusterId,
boolean clusterNoise,
DriverNdiHomeStatus status,
DriverNdiHomeClassificationReason reason
) {
}

View File

@ -0,0 +1,13 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
public enum DriverNdiHomeClassificationReason {
VEHICLE_CHANGED,
CARD_REMOVED_OVER_THRESHOLD,
REST_OVER_VERY_LONG_THRESHOLD,
NO_POSITION_LONG_REST,
NO_POSITION_SHORT_REST,
COMPANY_HOME_CLUSTER,
DRIVER_HOME_CLUSTER,
LONG_REST_OUTSIDE_HOME_CLUSTER,
SHORT_REST
}

View File

@ -0,0 +1,33 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public record DriverNdiHomeClassificationResult(
String driverKey,
int nonDrivingIntervalCount,
int currentLongLocationObservationCount,
int cachedActualDriverObservationCount,
int cachedOtherDriverObservationCount,
int companyHomeClusterCount,
int driverHomeClusterCount,
List<DriverNdiHomeClassification> classifications,
List<DriverNdiLocationCluster> clusters,
Set<String> companyHomeClusterIds,
Set<String> driverHomeClusterIds,
List<String> notes
) {
public DriverNdiHomeClassificationResult {
classifications = classifications == null ? List.of() : List.copyOf(classifications);
clusters = clusters == null ? List.of() : List.copyOf(clusters);
companyHomeClusterIds = companyHomeClusterIds == null
? Set.of()
: Collections.unmodifiableSet(new LinkedHashSet<>(companyHomeClusterIds));
driverHomeClusterIds = driverHomeClusterIds == null
? Set.of()
: Collections.unmodifiableSet(new LinkedHashSet<>(driverHomeClusterIds));
notes = notes == null ? List.of() : List.copyOf(notes);
}
}

View File

@ -0,0 +1,26 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record DriverNdiHomeClassificationScopeResult(
String corpusKey,
int currentObservationCount,
int cachedObservationCount,
int clusterCount,
Map<String, DriverNdiHomeClassificationResult> driverResults,
List<String> notes
) {
public DriverNdiHomeClassificationScopeResult {
driverResults = driverResults == null
? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(driverResults));
notes = notes == null ? List.of() : List.copyOf(notes);
}
public DriverNdiHomeClassificationResult resultForDriver(String driverKey) {
return driverResults.get(driverKey);
}
}

View File

@ -0,0 +1,6 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
public enum DriverNdiHomeStatus {
HOME,
NOT_HOME
}

View File

@ -0,0 +1,16 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
public record DriverNdiLocationCluster(
String clusterId,
double centroidLatitude,
double centroidLongitude,
int totalVisitCount,
int distinctDriverCount,
int actualDriverVisitCount,
int otherDriverVisitCount,
double companyVisitSharePercent,
double actualDriverVisitSharePercent,
boolean companyHome,
boolean actualDriverHome
) {
}

View File

@ -0,0 +1,18 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
import java.time.Instant;
import java.util.List;
public record DriverNdiLocationCorpusSnapshot(
String corpusKey,
int observationCountBeforeMerge,
int addedObservationCount,
int observationCountAfterMerge,
List<DriverNdiLocationObservation> observations,
Instant updatedAt,
Instant expiresAt
) {
public DriverNdiLocationCorpusSnapshot {
observations = observations == null ? List.of() : List.copyOf(observations);
}
}

View File

@ -0,0 +1,67 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
import java.time.OffsetDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.UUID;
public record DriverNdiLocationObservation(
String observationId,
String tenantKey,
List<UUID> sourceSessionIds,
UUID compositeSessionId,
String driverKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
long durationSeconds,
double latitude,
double longitude,
String locationSource,
String geoEventId,
String geoEventDomain,
Long geoDistanceSeconds,
String previousDrivingSourceIntervalId,
String nextDrivingSourceIntervalId,
String previousRegistrationKey,
String nextRegistrationKey,
String previousVehicleKey,
String nextVehicleKey
) {
public DriverNdiLocationObservation {
sourceSessionIds = sourceSessionIds == null ? List.of() : List.copyOf(sourceSessionIds);
}
public DriverNdiLocationObservation mergeProvenance(DriverNdiLocationObservation other) {
if (other == null || !observationId.equals(other.observationId())) {
return this;
}
LinkedHashSet<UUID> mergedSessionIds = new LinkedHashSet<>(sourceSessionIds);
mergedSessionIds.addAll(other.sourceSessionIds());
return new DriverNdiLocationObservation(
observationId,
other.tenantKey() != null ? other.tenantKey() : tenantKey,
List.copyOf(mergedSessionIds),
other.compositeSessionId() != null ? other.compositeSessionId() : compositeSessionId,
other.driverKey() != null ? other.driverKey() : driverKey,
other.startedAt() != null ? other.startedAt() : startedAt,
other.endedAt() != null ? other.endedAt() : endedAt,
other.durationSeconds(),
other.latitude(),
other.longitude(),
other.locationSource() != null ? other.locationSource() : locationSource,
other.geoEventId() != null ? other.geoEventId() : geoEventId,
other.geoEventDomain() != null ? other.geoEventDomain() : geoEventDomain,
other.geoDistanceSeconds() != null ? other.geoDistanceSeconds() : geoDistanceSeconds,
other.previousDrivingSourceIntervalId() != null
? other.previousDrivingSourceIntervalId()
: previousDrivingSourceIntervalId,
other.nextDrivingSourceIntervalId() != null
? other.nextDrivingSourceIntervalId()
: nextDrivingSourceIntervalId,
other.previousRegistrationKey() != null ? other.previousRegistrationKey() : previousRegistrationKey,
other.nextRegistrationKey() != null ? other.nextRegistrationKey() : nextRegistrationKey,
other.previousVehicleKey() != null ? other.previousVehicleKey() : previousVehicleKey,
other.nextVehicleKey() != null ? other.nextVehicleKey() : nextVehicleKey
);
}
}

View File

@ -0,0 +1,203 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.service;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiLocationObservation;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class DriverNdiDbscanClusterer {
public static final String NOISE_CLUSTER_ID = "NOISE";
private static final int UNVISITED = Integer.MIN_VALUE;
private static final int NOISE = -1;
private static final double EARTH_RADIUS_METERS = 6_371_008.8d;
public ClusterResult cluster(
List<DriverNdiLocationObservation> observations,
double epsilonMeters,
int minimumPoints
) {
List<DriverNdiLocationObservation> points = observations == null ? List.of() : List.copyOf(observations);
double effectiveEpsilonMeters = Double.isFinite(epsilonMeters)
? Math.max(0.0d, epsilonMeters)
: 0.0d;
int effectiveMinimumPoints = Math.max(1, minimumPoints);
if (points.isEmpty()) {
return new ClusterResult(Map.of(), Map.of());
}
int[] labels = new int[points.size()];
java.util.Arrays.fill(labels, UNVISITED);
int clusterNumber = 0;
for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) {
if (labels[pointIndex] != UNVISITED) {
continue;
}
List<Integer> neighbours = neighbours(points, pointIndex, effectiveEpsilonMeters);
if (neighbours.size() < effectiveMinimumPoints) {
labels[pointIndex] = NOISE;
continue;
}
expandCluster(points, labels, pointIndex, neighbours, clusterNumber, effectiveEpsilonMeters, effectiveMinimumPoints);
clusterNumber++;
}
Map<Integer, List<DriverNdiLocationObservation>> numericClusters = new HashMap<>();
for (int index = 0; index < points.size(); index++) {
if (labels[index] >= 0) {
numericClusters.computeIfAbsent(labels[index], ignored -> new ArrayList<>()).add(points.get(index));
}
}
List<RawCluster> sortedClusters = numericClusters.entrySet().stream()
.map(entry -> RawCluster.of(entry.getKey(), entry.getValue()))
.sorted(Comparator
.comparingDouble(RawCluster::centroidLatitude)
.thenComparingDouble(RawCluster::centroidLongitude)
.thenComparingInt(RawCluster::numericId))
.toList();
Map<Integer, String> clusterIdByNumericId = new HashMap<>();
LinkedHashMap<String, ClusterMembers> clusters = new LinkedHashMap<>();
for (int index = 0; index < sortedClusters.size(); index++) {
RawCluster raw = sortedClusters.get(index);
String clusterId = "CLUSTER-" + String.format("%03d", index + 1);
clusterIdByNumericId.put(raw.numericId(), clusterId);
clusters.put(clusterId, new ClusterMembers(
clusterId,
raw.centroidLatitude(),
raw.centroidLongitude(),
raw.members()
));
}
LinkedHashMap<String, String> assignmentByObservationId = new LinkedHashMap<>();
for (int index = 0; index < points.size(); index++) {
String clusterId = labels[index] == NOISE
? NOISE_CLUSTER_ID
: clusterIdByNumericId.get(labels[index]);
assignmentByObservationId.put(points.get(index).observationId(), clusterId);
}
return new ClusterResult(Map.copyOf(assignmentByObservationId), Map.copyOf(clusters));
}
private void expandCluster(
List<DriverNdiLocationObservation> points,
int[] labels,
int seedIndex,
List<Integer> seedNeighbours,
int clusterNumber,
double epsilonMeters,
int minimumPoints
) {
labels[seedIndex] = clusterNumber;
Deque<Integer> queue = new ArrayDeque<>();
Set<Integer> queued = new LinkedHashSet<>();
for (Integer neighbour : seedNeighbours) {
if (neighbour != seedIndex && queued.add(neighbour)) {
queue.addLast(neighbour);
}
}
while (!queue.isEmpty()) {
int candidate = queue.removeFirst();
if (labels[candidate] == NOISE) {
labels[candidate] = clusterNumber;
}
if (labels[candidate] != UNVISITED) {
continue;
}
labels[candidate] = clusterNumber;
List<Integer> candidateNeighbours = neighbours(points, candidate, epsilonMeters);
if (candidateNeighbours.size() >= minimumPoints) {
for (Integer neighbour : candidateNeighbours) {
if (queued.add(neighbour)) {
queue.addLast(neighbour);
}
}
}
}
}
private List<Integer> neighbours(
List<DriverNdiLocationObservation> points,
int pointIndex,
double epsilonMeters
) {
DriverNdiLocationObservation source = points.get(pointIndex);
List<Integer> neighbours = new ArrayList<>();
for (int candidateIndex = 0; candidateIndex < points.size(); candidateIndex++) {
DriverNdiLocationObservation candidate = points.get(candidateIndex);
if (haversineMeters(
source.latitude(),
source.longitude(),
candidate.latitude(),
candidate.longitude()
) <= epsilonMeters) {
neighbours.add(candidateIndex);
}
}
return neighbours;
}
static double haversineMeters(
double latitudeA,
double longitudeA,
double latitudeB,
double longitudeB
) {
double latitudeDelta = Math.toRadians(latitudeB - latitudeA);
double longitudeDelta = Math.toRadians(longitudeB - longitudeA);
double latitudeARadians = Math.toRadians(latitudeA);
double latitudeBRadians = Math.toRadians(latitudeB);
double haversine = Math.sin(latitudeDelta / 2.0d) * Math.sin(latitudeDelta / 2.0d)
+ Math.cos(latitudeARadians) * Math.cos(latitudeBRadians)
* Math.sin(longitudeDelta / 2.0d) * Math.sin(longitudeDelta / 2.0d);
double normalizedHaversine = Math.max(0.0d, Math.min(1.0d, haversine));
double angularDistance = 2.0d * Math.atan2(
Math.sqrt(normalizedHaversine),
Math.sqrt(1.0d - normalizedHaversine)
);
return EARTH_RADIUS_METERS * angularDistance;
}
public record ClusterResult(
Map<String, String> assignmentByObservationId,
Map<String, ClusterMembers> clusters
) {
}
public record ClusterMembers(
String clusterId,
double centroidLatitude,
double centroidLongitude,
List<DriverNdiLocationObservation> members
) {
public ClusterMembers {
members = members == null ? List.of() : List.copyOf(members);
}
}
private record RawCluster(
int numericId,
double centroidLatitude,
double centroidLongitude,
List<DriverNdiLocationObservation> members
) {
private static RawCluster of(int numericId, List<DriverNdiLocationObservation> members) {
double latitude = members.stream().mapToDouble(DriverNdiLocationObservation::latitude).average().orElse(0.0d);
double longitude = members.stream().mapToDouble(DriverNdiLocationObservation::longitude).average().orElse(0.0d);
return new RawCluster(numericId, latitude, longitude, List.copyOf(members));
}
}
}

View File

@ -0,0 +1,572 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassification;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationReason;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeStatus;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiLocationCluster;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiLocationCorpusSnapshot;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiLocationObservation;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeRestCoverageInterval;
import at.procon.eventhub.processing.driverworkingtime.service.DriverWorkingTimeReusableProjectionBuilder;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import org.springframework.stereotype.Service;
@Service
public class DriverNdiHomeClassificationService {
private static final String ALGORITHM_VERSION = "NDI_HOME_V1";
private final DriverWorkingTimeReusableProjectionBuilder projectionBuilder;
private final DriverNdiLocationCorpusCache locationCorpusCache;
private final DriverNdiDbscanClusterer clusterer;
private final EventHubProperties properties;
public DriverNdiHomeClassificationService(
DriverWorkingTimeReusableProjectionBuilder projectionBuilder,
DriverNdiLocationCorpusCache locationCorpusCache,
DriverNdiDbscanClusterer clusterer,
EventHubProperties properties
) {
this.projectionBuilder = projectionBuilder;
this.locationCorpusCache = locationCorpusCache;
this.clusterer = clusterer;
this.properties = properties;
}
public DriverNdiHomeClassificationScopeResult classifyPreparedInputs(
UnifiedRuntimeProcessingApiRequest request,
Map<String, DriverWorkingTimePreparedInput> preparedInputs
) {
LinkedHashMap<String, List<DriverWorkingTimeRestCoverageInterval>> evidenceByDriver = new LinkedHashMap<>();
if (preparedInputs != null) {
preparedInputs.entrySet().stream()
.filter(entry -> entry.getKey() != null
&& entry.getValue() != null
&& entry.getValue().processingInput() != null)
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> evidenceByDriver.put(
entry.getKey(),
projectionBuilder.buildAllNonDrivingIntervalCoverage(entry.getValue().processingInput())
));
}
return classifyEvidence(request, evidenceByDriver);
}
public DriverNdiHomeClassificationScopeResult classifyEvidence(
UnifiedRuntimeProcessingApiRequest request,
Map<String, List<DriverWorkingTimeRestCoverageInterval>> evidenceByDriver
) {
EventHubProperties.Processing settings = properties.getRuntimeProcessing();
long longThresholdSeconds = settings.getNdiLongMinutes() * 60L;
long veryLongThresholdSeconds = settings.getNdiVeryLongMinutes() * 60L;
double cardRemovalThresholdPercent = settings.getNdiCardRemovalPercent();
double visitShareThresholdPercent = settings.getNdiVisitSharePercent();
List<UUID> sourceSessionIds = sourceSessionIds(request);
UUID compositeSessionId = request == null ? null : request.compositeSessionId();
String tenantKey = normalizedTenantKey(request == null ? null : request.tenantKey());
String corpusKey = corpusKey(
request,
tenantKey,
settings.getNdiLocationCacheNamespace(),
settings.getNdiLongMinutes()
);
LinkedHashMap<String, List<DriverWorkingTimeRestCoverageInterval>> safeEvidenceByDriver = new LinkedHashMap<>();
if (evidenceByDriver != null) {
evidenceByDriver.entrySet().stream()
.filter(entry -> entry.getKey() != null && !entry.getKey().isBlank())
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> safeEvidenceByDriver.put(
entry.getKey(),
safeList(entry.getValue()).stream()
.filter(Objects::nonNull)
.sorted(Comparator
.comparing(DriverWorkingTimeRestCoverageInterval::startedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(DriverWorkingTimeRestCoverageInterval::endedAt, Comparator.nullsLast(Comparator.naturalOrder())))
.toList()
));
}
List<DriverNdiLocationObservation> currentObservations = new ArrayList<>();
Map<String, DriverNdiLocationObservation> currentObservationByIntervalId = new HashMap<>();
for (Map.Entry<String, List<DriverWorkingTimeRestCoverageInterval>> entry : safeEvidenceByDriver.entrySet()) {
for (DriverWorkingTimeRestCoverageInterval evidence : entry.getValue()) {
if (evidence.durationSeconds() <= longThresholdSeconds) {
continue;
}
ResolvedPosition position = resolvePosition(evidence);
if (position == null) {
continue;
}
String intervalId = intervalId(evidence);
DriverNdiLocationObservation observation = new DriverNdiLocationObservation(
observationId(tenantKey, intervalId),
tenantKey,
observationSourceSessionIds(evidence.sessionId(), sourceSessionIds),
compositeSessionId,
entry.getKey(),
evidence.startedAt(),
evidence.endedAt(),
evidence.durationSeconds(),
position.latitude(),
position.longitude(),
position.source(),
position.geoEventId(),
position.geoEventDomain(),
position.geoDistanceSeconds(),
evidence.previousDrivingSourceIntervalId(),
evidence.nextDrivingSourceIntervalId(),
evidence.previousRegistrationKey(),
evidence.nextRegistrationKey(),
evidence.previousVehicleKey(),
evidence.nextVehicleKey()
);
currentObservations.add(observation);
currentObservationByIntervalId.put(intervalId, observation);
}
}
DriverNdiLocationCorpusSnapshot corpus = locationCorpusCache.merge(corpusKey, currentObservations);
DriverNdiDbscanClusterer.ClusterResult clusterResult = clusterer.cluster(
corpus.observations(),
settings.getNdiDbscanEpsMeters(),
settings.getNdiDbscanMinPoints()
);
Set<String> companyHomeClusterIds = determineCompanyHomeClusters(
clusterResult,
corpus.observations().size(),
visitShareThresholdPercent
);
LinkedHashMap<String, DriverNdiHomeClassificationResult> driverResults = new LinkedHashMap<>();
for (Map.Entry<String, List<DriverWorkingTimeRestCoverageInterval>> entry : safeEvidenceByDriver.entrySet()) {
String driverKey = entry.getKey();
List<DriverNdiLocationObservation> cachedDriverObservations = corpus.observations().stream()
.filter(observation -> Objects.equals(driverKey, observation.driverKey()))
.toList();
Set<String> driverHomeClusterIds = determineDriverHomeClusters(
driverKey,
clusterResult,
cachedDriverObservations.size(),
companyHomeClusterIds,
visitShareThresholdPercent
);
List<DriverNdiLocationCluster> driverClusters = buildDriverClusterView(
driverKey,
clusterResult,
corpus.observations().size(),
cachedDriverObservations.size(),
companyHomeClusterIds,
driverHomeClusterIds
);
List<DriverNdiHomeClassification> classifications = entry.getValue().stream()
.map(evidence -> classifyInterval(
evidence,
currentObservationByIntervalId.get(intervalId(evidence)),
clusterResult.assignmentByObservationId(),
companyHomeClusterIds,
driverHomeClusterIds,
longThresholdSeconds,
veryLongThresholdSeconds,
cardRemovalThresholdPercent
))
.toList();
int currentDriverObservationCount = (int) currentObservations.stream()
.filter(observation -> Objects.equals(driverKey, observation.driverKey()))
.count();
int cachedOtherDriverObservationCount = corpus.observations().size() - cachedDriverObservations.size();
List<String> notes = List.of(
"NDI classification used ordered rules from ndi_home_classification_en.md.",
"Location learning used " + cachedDriverObservations.size() + " cached observation(s) for the actual driver and "
+ cachedOtherDriverObservationCount + " observation(s) from other drivers.",
"DBSCAN parameters: eps=" + settings.getNdiDbscanEpsMeters() + "m, minPoints="
+ settings.getNdiDbscanMinPoints() + "."
);
driverResults.put(driverKey, new DriverNdiHomeClassificationResult(
driverKey,
classifications.size(),
currentDriverObservationCount,
cachedDriverObservations.size(),
cachedOtherDriverObservationCount,
companyHomeClusterIds.size(),
driverHomeClusterIds.size(),
classifications,
driverClusters,
companyHomeClusterIds,
driverHomeClusterIds,
notes
));
}
List<String> notes = new ArrayList<>();
notes.add("NDI location corpus " + corpus.corpusKey() + " contains " + corpus.observationCountAfterMerge()
+ " observation(s); " + corpus.addedObservationCount() + " were added by this execution.");
notes.add("Current execution contributed " + currentObservations.size() + " long NDI location observation(s) from "
+ safeEvidenceByDriver.size() + " driver(s).");
if (safeEvidenceByDriver.size() <= 1) {
notes.add("Only one driver was present in the current execution; company-home detection may still use cached observations from other drivers.");
}
return new DriverNdiHomeClassificationScopeResult(
corpus.corpusKey(),
currentObservations.size(),
corpus.observationCountAfterMerge(),
clusterResult.clusters().size(),
driverResults,
notes
);
}
private DriverNdiHomeClassification classifyInterval(
DriverWorkingTimeRestCoverageInterval evidence,
DriverNdiLocationObservation observation,
Map<String, String> assignmentByObservationId,
Set<String> companyHomeClusterIds,
Set<String> driverHomeClusterIds,
long longThresholdSeconds,
long veryLongThresholdSeconds,
double cardRemovalThresholdPercent
) {
ResolvedPosition position = resolvePosition(evidence);
String clusterId = observation == null ? null : assignmentByObservationId.get(observation.observationId());
boolean clusterNoise = DriverNdiDbscanClusterer.NOISE_CLUSTER_ID.equals(clusterId);
DriverNdiHomeStatus status;
DriverNdiHomeClassificationReason reason;
if (vehicleChanged(evidence)) {
status = DriverNdiHomeStatus.HOME;
reason = DriverNdiHomeClassificationReason.VEHICLE_CHANGED;
} else if (evidence.cardAbsentCoveragePercent() > cardRemovalThresholdPercent) {
status = DriverNdiHomeStatus.HOME;
reason = DriverNdiHomeClassificationReason.CARD_REMOVED_OVER_THRESHOLD;
} else if (evidence.durationSeconds() > veryLongThresholdSeconds) {
status = DriverNdiHomeStatus.HOME;
reason = DriverNdiHomeClassificationReason.REST_OVER_VERY_LONG_THRESHOLD;
} else if (position == null) {
if (evidence.durationSeconds() > longThresholdSeconds) {
status = DriverNdiHomeStatus.HOME;
reason = DriverNdiHomeClassificationReason.NO_POSITION_LONG_REST;
} else {
status = DriverNdiHomeStatus.NOT_HOME;
reason = DriverNdiHomeClassificationReason.NO_POSITION_SHORT_REST;
}
} else if (evidence.durationSeconds() > longThresholdSeconds) {
if (clusterId != null && companyHomeClusterIds.contains(clusterId)) {
status = DriverNdiHomeStatus.HOME;
reason = DriverNdiHomeClassificationReason.COMPANY_HOME_CLUSTER;
} else if (clusterId != null && driverHomeClusterIds.contains(clusterId)) {
status = DriverNdiHomeStatus.HOME;
reason = DriverNdiHomeClassificationReason.DRIVER_HOME_CLUSTER;
} else {
status = DriverNdiHomeStatus.NOT_HOME;
reason = DriverNdiHomeClassificationReason.LONG_REST_OUTSIDE_HOME_CLUSTER;
}
} else {
status = DriverNdiHomeStatus.NOT_HOME;
reason = DriverNdiHomeClassificationReason.SHORT_REST;
}
return new DriverNdiHomeClassification(
intervalId(evidence),
evidence,
position == null ? null : position.latitude(),
position == null ? null : position.longitude(),
position == null ? null : position.source(),
clusterId,
clusterNoise,
status,
reason
);
}
private Set<String> determineCompanyHomeClusters(
DriverNdiDbscanClusterer.ClusterResult clusterResult,
int totalObservationCount,
double visitShareThresholdPercent
) {
if (totalObservationCount <= 0) {
return Set.of();
}
LinkedHashSet<String> result = new LinkedHashSet<>();
clusterResult.clusters().values().stream()
.sorted(Comparator.comparing(DriverNdiDbscanClusterer.ClusterMembers::clusterId))
.forEach(cluster -> {
double share = cluster.members().size() * 100.0d / totalObservationCount;
if (share > visitShareThresholdPercent) {
result.add(cluster.clusterId());
}
});
return Collections.unmodifiableSet(result);
}
private Set<String> determineDriverHomeClusters(
String driverKey,
DriverNdiDbscanClusterer.ClusterResult clusterResult,
int driverObservationCount,
Set<String> companyHomeClusterIds,
double visitShareThresholdPercent
) {
if (driverObservationCount <= 0) {
return Set.of();
}
LinkedHashSet<String> result = new LinkedHashSet<>();
clusterResult.clusters().values().stream()
.sorted(Comparator.comparing(DriverNdiDbscanClusterer.ClusterMembers::clusterId))
.forEach(cluster -> {
long driverVisits = cluster.members().stream()
.filter(observation -> Objects.equals(driverKey, observation.driverKey()))
.count();
double share = driverVisits * 100.0d / driverObservationCount;
if (share > visitShareThresholdPercent && !companyHomeClusterIds.contains(cluster.clusterId())) {
result.add(cluster.clusterId());
}
});
return Collections.unmodifiableSet(result);
}
private List<DriverNdiLocationCluster> buildDriverClusterView(
String driverKey,
DriverNdiDbscanClusterer.ClusterResult clusterResult,
int totalObservationCount,
int driverObservationCount,
Set<String> companyHomeClusterIds,
Set<String> driverHomeClusterIds
) {
return clusterResult.clusters().values().stream()
.sorted(Comparator.comparing(DriverNdiDbscanClusterer.ClusterMembers::clusterId))
.map(cluster -> {
int actualDriverVisits = (int) cluster.members().stream()
.filter(observation -> Objects.equals(driverKey, observation.driverKey()))
.count();
int totalVisits = cluster.members().size();
int distinctDriverCount = (int) cluster.members().stream()
.map(DriverNdiLocationObservation::driverKey)
.filter(Objects::nonNull)
.distinct()
.count();
return new DriverNdiLocationCluster(
cluster.clusterId(),
cluster.centroidLatitude(),
cluster.centroidLongitude(),
totalVisits,
distinctDriverCount,
actualDriverVisits,
totalVisits - actualDriverVisits,
totalObservationCount == 0 ? 0.0d : totalVisits * 100.0d / totalObservationCount,
driverObservationCount == 0 ? 0.0d : actualDriverVisits * 100.0d / driverObservationCount,
companyHomeClusterIds.contains(cluster.clusterId()),
driverHomeClusterIds.contains(cluster.clusterId())
);
})
.toList();
}
private boolean vehicleChanged(DriverWorkingTimeRestCoverageInterval evidence) {
if (known(evidence.previousVehicleKey()) && known(evidence.nextVehicleKey())) {
return knownAndDifferent(evidence.previousVehicleKey(), evidence.nextVehicleKey());
}
return knownAndDifferent(evidence.previousRegistrationKey(), evidence.nextRegistrationKey());
}
private boolean known(String value) {
return value != null && !value.isBlank();
}
private boolean knownAndDifferent(String left, String right) {
return left != null && !left.isBlank()
&& right != null && !right.isBlank()
&& !left.trim().equalsIgnoreCase(right.trim());
}
private ResolvedPosition resolvePosition(DriverWorkingTimeRestCoverageInterval evidence) {
if (validCoordinates(evidence.beginLatitude(), evidence.beginLongitude())) {
return new ResolvedPosition(
evidence.beginLatitude(),
evidence.beginLongitude(),
"PREVIOUS_DRIVE_END",
evidence.beginGeoEventId(),
evidence.beginGeoEventDomain(),
evidence.beginGeoDistanceSeconds()
);
}
if (validCoordinates(evidence.endLatitude(), evidence.endLongitude())) {
return new ResolvedPosition(
evidence.endLatitude(),
evidence.endLongitude(),
"NEXT_DRIVE_START",
evidence.endGeoEventId(),
evidence.endGeoEventDomain(),
evidence.endGeoDistanceSeconds()
);
}
return null;
}
private boolean validCoordinates(Double latitude, Double longitude) {
return latitude != null
&& longitude != null
&& Double.isFinite(latitude)
&& Double.isFinite(longitude)
&& latitude >= -90.0d
&& latitude <= 90.0d
&& longitude >= -180.0d
&& longitude <= 180.0d;
}
private List<UUID> observationSourceSessionIds(
UUID evidenceSessionId,
List<UUID> requestSessionIds
) {
if (evidenceSessionId != null) {
return List.of(evidenceSessionId);
}
return requestSessionIds == null ? List.of() : requestSessionIds;
}
private List<UUID> sourceSessionIds(UnifiedRuntimeProcessingApiRequest request) {
if (request == null) {
return List.of();
}
LinkedHashSet<UUID> values = new LinkedHashSet<>();
if (request.sessionId() != null) {
values.add(request.sessionId());
}
if (request.sessionIds() != null) {
request.sessionIds().stream().filter(Objects::nonNull).sorted().forEach(values::add);
}
if (request.sourceInputs() != null) {
request.sourceInputs().stream().filter(Objects::nonNull).forEach(sourceInput -> {
if (sourceInput.sessionId() != null) {
values.add(sourceInput.sessionId());
}
if (sourceInput.sessionIds() != null) {
sourceInput.sessionIds().stream().filter(Objects::nonNull).sorted().forEach(values::add);
}
});
}
return List.copyOf(values);
}
private String corpusKey(
UnifiedRuntimeProcessingApiRequest request,
String tenantKey,
String cacheNamespace,
int longThresholdMinutes
) {
boolean fileSessionCorpus = isFileSessionOnly(request);
if (!fileSessionCorpus) {
return tenantKey + "|RUNTIME|" + ALGORITHM_VERSION + "|LONG=" + longThresholdMinutes;
}
String scope;
if (request != null && request.tenantKey() != null && !request.tenantKey().isBlank()) {
scope = "TENANT=" + tenantKey;
} else {
String normalizedNamespace = cacheNamespace == null || cacheNamespace.isBlank()
? "default"
: cacheNamespace.trim();
scope = "NAMESPACE=" + normalizedNamespace;
}
return scope + "|FILE_SESSION|" + ALGORITHM_VERSION + "|LONG=" + longThresholdMinutes;
}
private boolean isFileSessionOnly(UnifiedRuntimeProcessingApiRequest request) {
if (request == null) {
return false;
}
if (request.sourceInputs() != null && !request.sourceInputs().isEmpty()) {
List<at.procon.eventhub.processing.dto.UnifiedRuntimeSourceInputApiRequest> sourceInputs =
request.sourceInputs().stream().filter(Objects::nonNull).toList();
return !sourceInputs.isEmpty() && sourceInputs.stream().allMatch(sourceInput ->
sourceInput.sourceFamily() == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION);
}
if (request.sourceFamilies() != null && !request.sourceFamilies().isEmpty()) {
return request.sourceFamilies().stream().allMatch(sourceFamily ->
sourceFamily == UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION);
}
return request.sessionId() != null
|| (request.sessionIds() != null && !request.sessionIds().isEmpty())
|| request.compositeSessionId() != null;
}
private String normalizedTenantKey(String tenantKey) {
return tenantKey == null || tenantKey.isBlank() ? "DEFAULT" : tenantKey.trim();
}
private String intervalId(DriverWorkingTimeRestCoverageInterval evidence) {
String raw = String.join("|",
nullSafe(evidence.driverKey()),
nullSafe(evidence.startedAt()),
nullSafe(evidence.endedAt()),
nullSafe(evidence.previousDrivingSourceIntervalId()),
nullSafe(evidence.nextDrivingSourceIntervalId()),
nullSafe(evidence.previousVehicleKey()),
nullSafe(evidence.nextVehicleKey()),
nullSafe(evidence.previousRegistrationKey()),
nullSafe(evidence.nextRegistrationKey())
);
return "NDI-" + shortHash(raw);
}
private String observationId(String tenantKey, String intervalId) {
return "NDI-LOC-" + shortHash(tenantKey + "|" + intervalId);
}
private String shortHash(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder(24);
for (int index = 0; index < 12; index++) {
result.append(String.format(Locale.ROOT, "%02x", digest[index]));
}
return result.toString();
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is not available", ex);
}
}
private String nullSafe(Object value) {
return value == null ? "" : value.toString();
}
private <T> List<T> safeList(List<T> values) {
return values == null ? List.of() : values;
}
private record ResolvedPosition(
double latitude,
double longitude,
String source,
String geoEventId,
String geoEventDomain,
Long geoDistanceSeconds
) {
}
}

View File

@ -0,0 +1,119 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiLocationCorpusSnapshot;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiLocationObservation;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import org.springframework.stereotype.Service;
@Service
public class DriverNdiLocationCorpusCache {
private final EventHubProperties properties;
private final ConcurrentMap<String, CachedCorpus> corpora = new ConcurrentHashMap<>();
public DriverNdiLocationCorpusCache(EventHubProperties properties) {
this.properties = properties;
}
public DriverNdiLocationCorpusSnapshot merge(
String corpusKey,
List<DriverNdiLocationObservation> newObservations
) {
String normalizedKey = normalizeCorpusKey(corpusKey);
Instant now = Instant.now();
Duration ttl = properties.getRuntimeProcessing().getNdiLocationCacheTtl();
int maxObservations = properties.getRuntimeProcessing().getNdiLocationCacheMaxObservations();
MergeMetrics metrics = new MergeMetrics();
CachedCorpus updated = corpora.compute(normalizedKey, (key, existing) -> {
LinkedHashMap<String, DriverNdiLocationObservation> observations = new LinkedHashMap<>();
if (existing != null && existing.expiresAt().isAfter(now)) {
observations.putAll(existing.observationsById());
}
metrics.before = observations.size();
for (DriverNdiLocationObservation observation : safeList(newObservations)) {
if (observation == null || observation.observationId() == null || observation.observationId().isBlank()) {
continue;
}
DriverNdiLocationObservation previous = observations.get(observation.observationId());
if (previous == null) {
observations.put(observation.observationId(), observation);
metrics.added++;
} else {
observations.put(observation.observationId(), previous.mergeProvenance(observation));
}
}
if (observations.size() > maxObservations) {
List<DriverNdiLocationObservation> retained = observations.values().stream()
.sorted(Comparator
.comparing(DriverNdiLocationObservation::endedAt, Comparator.nullsFirst(Comparator.naturalOrder()))
.reversed()
.thenComparing(DriverNdiLocationObservation::observationId))
.limit(maxObservations)
.toList();
observations.clear();
retained.stream()
.sorted(Comparator
.comparing(DriverNdiLocationObservation::startedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(DriverNdiLocationObservation::observationId))
.forEach(value -> observations.put(value.observationId(), value));
}
return new CachedCorpus(
Map.copyOf(observations),
now,
now.plus(ttl)
);
});
List<DriverNdiLocationObservation> sorted = updated.observationsById().values().stream()
.sorted(Comparator
.comparing(DriverNdiLocationObservation::startedAt, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(DriverNdiLocationObservation::driverKey, Comparator.nullsLast(String::compareTo))
.thenComparing(DriverNdiLocationObservation::observationId))
.toList();
return new DriverNdiLocationCorpusSnapshot(
normalizedKey,
metrics.before,
metrics.added,
sorted.size(),
sorted,
updated.updatedAt(),
updated.expiresAt()
);
}
public void clear() {
corpora.clear();
}
private String normalizeCorpusKey(String corpusKey) {
return corpusKey == null || corpusKey.isBlank() ? "DEFAULT|NDI_HOME_V1" : corpusKey.trim();
}
private <T> List<T> safeList(List<T> values) {
return values == null ? List.of() : values;
}
private record CachedCorpus(
Map<String, DriverNdiLocationObservation> observationsById,
Instant updatedAt,
Instant expiresAt
) {
}
private static final class MergeMetrics {
private int before;
private int added;
}
}

View File

@ -12,6 +12,7 @@ import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeSu
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVuCardAbsentInterval;
import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent;
import at.procon.eventhub.reference.CountryCodeNormalizer;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
@ -306,10 +307,22 @@ public class DriverWorkingTimeProcessingCore {
event.lifecycle(),
event.registrationKey(),
event.vehicleKey(),
event.countryCode(),
CountryCodeNormalizer.normalizeSupportEvent(
event.sourceFamily(),
event.sourceKind(),
event.countryCode()
),
event.regionCode(),
event.countryFrom(),
event.countryTo(),
CountryCodeNormalizer.normalizeSupportEvent(
event.sourceFamily(),
event.sourceKind(),
event.countryFrom()
),
CountryCodeNormalizer.normalizeSupportEvent(
event.sourceFamily(),
event.sourceKind(),
event.countryTo()
),
event.operation(),
event.latitude(),
event.longitude(),

View File

@ -111,6 +111,27 @@ public class DriverWorkingTimeReusableProjectionBuilder {
);
}
/**
* Reuses the complete card-absence and boundary-GNSS enrichment pipeline for every
* positive non-driving interval. Legacy daily/weekly-rest projections keep their
* configured minimum-rest threshold and are not changed by this method.
*/
public List<DriverWorkingTimeRestCoverageInterval> buildAllNonDrivingIntervalCoverage(
DriverWorkingTimeProcessingInput input
) {
if (input == null) {
return List.of();
}
DriverWorkingTimeDerivedProjectionBundle allNonDrivingProjection = buildDerivedProjectionBundle(
buildActivityIntervalInputEvents(input.activityIntervals()),
buildVehicleUsageIntervalInputEventsCommon(input.vehicleUsageIntervals()),
buildSupportGeoInputEventsCommon(input.sessionId(), input.supportEvidenceEvents()),
input.significantDrivingMinutes(),
0
);
return allNonDrivingProjection.dailyWeeklyRestCandidateCoverageIntervals();
}
private DriverWorkingTimeDerivedProjectionBundle buildDerivedProjectionBundle(
List<Map<String, Object>> activityInputEvents,
List<Map<String, Object>> vehicleUsageInputEvents,
@ -910,7 +931,7 @@ public class DriverWorkingTimeReusableProjectionBuilder {
)
.replace(
"${MINIMUM_REST_PERIOD_THRESHOLD_SECONDS}",
Long.toString(Math.max(1, minimumRestPeriodMinutes) * 60L)
Long.toString(Math.max(0, minimumRestPeriodMinutes) * 60L)
)
.replace(
"${REST_GEO_LOOKBACK_SECONDS}",

View File

@ -0,0 +1,35 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassification;
import java.time.OffsetDateTime;
import java.util.List;
/**
* A complete driver trip bounded by two HOME-classified non-driving intervals.
*
* <p>The active trip time starts when the start HOME interval ends and finishes when the
* end HOME interval starts. The two HOME classifications are retained as explicit boundary
* evidence. Every NOT_HOME classification between those boundaries is attached to the trip.</p>
*/
public record DriverClassifiedTrip(
String tripId,
String driverKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
long durationSeconds,
DriverNdiHomeClassification startHomeClassification,
DriverNdiHomeClassification endHomeClassification,
List<DriverNdiHomeClassification> containedNonHomeClassifications,
int drivingIntervalCount,
int countrySegmentCount,
List<DriverCountryTripSegment> countrySegments,
List<String> notes
) {
public DriverClassifiedTrip {
containedNonHomeClassifications = containedNonHomeClassifications == null
? List.of()
: List.copyOf(containedNonHomeClassifications);
countrySegments = countrySegments == null ? List.of() : List.copyOf(countrySegments);
notes = notes == null ? List.of() : List.copyOf(notes);
}
}

View File

@ -0,0 +1,71 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
public record DriverCountryTripSegment(
String segmentId,
String tripId,
String driverKey,
String registrationKey,
String vehicleKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
String countryCode,
String countryFrom,
String countryTo,
BigDecimal latitudeFrom,
BigDecimal longitudeFrom,
BigDecimal latitudeTo,
BigDecimal longitudeTo,
String positionFromEventId,
String positionToEventId,
DriverCountryTripSegmentBoundarySource endBoundarySource,
String boundaryEventId,
boolean boundaryCountryReverseGeocoded
) {
/**
* Compatibility constructor for callers that still create an unscoped segment.
*/
public DriverCountryTripSegment(
String segmentId,
String driverKey,
String registrationKey,
String vehicleKey,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
String countryFrom,
String countryTo,
BigDecimal latitudeFrom,
BigDecimal longitudeFrom,
BigDecimal latitudeTo,
BigDecimal longitudeTo,
String positionFromEventId,
String positionToEventId,
DriverCountryTripSegmentBoundarySource endBoundarySource,
String boundaryEventId,
boolean boundaryCountryReverseGeocoded
) {
this(
segmentId,
null,
driverKey,
registrationKey,
vehicleKey,
startedAt,
endedAt,
countryFrom,
countryFrom,
countryTo,
latitudeFrom,
longitudeFrom,
latitudeTo,
longitudeTo,
positionFromEventId,
positionToEventId,
endBoundarySource,
boundaryEventId,
boundaryCountryReverseGeocoded
);
}
}

View File

@ -0,0 +1,9 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model;
public enum DriverCountryTripSegmentBoundarySource {
EXPLICIT_BORDER_CROSSING,
GNSS_SOURCE_COUNTRY_CHANGE,
NOMINATIM_COUNTRY_CHANGE,
VEHICLE_CHANGE,
FINAL
}

View File

@ -0,0 +1,64 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model;
import java.util.List;
public record DriverCountryTripSegmentationResult(
String driverKey,
int drivingIntervalCount,
int supportingGeoEventCount,
int explicitBorderCrossingCount,
int reverseGeocodingRemoteRequestCount,
int reverseGeocodingCacheHitCount,
int unresolvedCoordinateCount,
int segmentCount,
int tripCount,
int unassignedNonHomeClassificationCount,
String reverseGeocodingAttribution,
List<DriverCountryTripSegment> segments,
List<DriverClassifiedTrip> trips,
List<String> notes,
List<String> warnings
) {
public DriverCountryTripSegmentationResult {
segments = segments == null ? List.of() : List.copyOf(segments);
trips = trips == null ? List.of() : List.copyOf(trips);
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
/**
* Compatibility constructor for the former flat country-segmentation result.
*/
public DriverCountryTripSegmentationResult(
String driverKey,
int drivingIntervalCount,
int supportingGeoEventCount,
int explicitBorderCrossingCount,
int reverseGeocodingRemoteRequestCount,
int reverseGeocodingCacheHitCount,
int unresolvedCoordinateCount,
int segmentCount,
String reverseGeocodingAttribution,
List<DriverCountryTripSegment> segments,
List<String> notes,
List<String> warnings
) {
this(
driverKey,
drivingIntervalCount,
supportingGeoEventCount,
explicitBorderCrossingCount,
reverseGeocodingRemoteRequestCount,
reverseGeocodingCacheHitCount,
unresolvedCoordinateCount,
segmentCount,
0,
0,
reverseGeocodingAttribution,
segments,
List.of(),
notes,
warnings
);
}
}

View File

@ -0,0 +1,29 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public record DriverCountryTripSegmentationScopeResult(
int driverCount,
int segmentCount,
int reverseGeocodingRemoteRequestCount,
int reverseGeocodingCacheHitCount,
int unresolvedCoordinateCount,
String reverseGeocodingAttribution,
Map<String, DriverCountryTripSegmentationResult> driverResults,
List<String> notes,
List<String> warnings
) {
public DriverCountryTripSegmentationScopeResult {
driverResults = driverResults == null
? Map.of()
: java.util.Collections.unmodifiableMap(new LinkedHashMap<>(driverResults));
notes = notes == null ? List.of() : List.copyOf(notes);
warnings = warnings == null ? List.of() : List.copyOf(warnings);
}
public DriverCountryTripSegmentationResult resultForDriver(String driverKey) {
return driverKey == null ? null : driverResults.get(driverKey);
}
}

View File

@ -0,0 +1,19 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.service;
/**
* Package-local compatibility facade. Country-code normalization is shared by
* the complete runtime pipeline through the reference-layer implementation.
*/
final class CountryCodeNormalizer {
private CountryCodeNormalizer() {
}
static String normalizeTachograph(String value) {
return at.procon.eventhub.reference.CountryCodeNormalizer.normalizeTachograph(value);
}
static String normalizeIso(String value) {
return at.procon.eventhub.reference.CountryCodeNormalizer.normalizeIso(value);
}
}

View File

@ -0,0 +1,939 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.service;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.geocoding.model.GeoCountryResolution;
import at.procon.eventhub.geocoding.model.GeoCountryResolutionStatus;
import at.procon.eventhub.geocoding.service.GeoCountryResolver;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassification;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeStatus;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverClassifiedTrip;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegment;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentBoundarySource;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationResult;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationScopeResult;
import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.springframework.stereotype.Service;
@Service
public class DriverCountryTripSegmentationService {
private static final String ATTRIBUTION = "Data © OpenStreetMap contributors, ODbL 1.0";
private final GeoCountryResolver countryResolver;
private final EventHubProperties properties;
public DriverCountryTripSegmentationService(
GeoCountryResolver countryResolver,
EventHubProperties properties
) {
this.countryResolver = countryResolver;
this.properties = properties;
}
/**
* Compatibility entry point retaining the former flat whole-timeline segmentation.
*/
public DriverCountryTripSegmentationScopeResult segmentPreparedInputs(
Map<String, DriverWorkingTimePreparedInput> preparedInputs
) {
return segmentPreparedInputs(preparedInputs, null);
}
/**
* Builds complete trips between consecutive HOME classifications and calculates country
* segments independently inside every trip.
*/
public DriverCountryTripSegmentationScopeResult segmentPreparedInputs(
Map<String, DriverWorkingTimePreparedInput> preparedInputs,
DriverNdiHomeClassificationScopeResult homeClassificationScope
) {
int maxRemoteLookups = properties.getReverseGeocoding()
.getNominatim()
.getMaxRemoteLookupsPerExecution();
LookupBudget budget = new LookupBudget(maxRemoteLookups);
LinkedHashMap<String, DriverCountryTripSegmentationResult> driverResults = new LinkedHashMap<>();
List<String> scopeWarnings = new ArrayList<>();
if (preparedInputs != null) {
preparedInputs.entrySet().stream()
.filter(entry -> entry.getKey() != null
&& !entry.getKey().isBlank()
&& entry.getValue() != null
&& entry.getValue().processingInput() != null)
.sorted(Map.Entry.comparingByKey())
.forEach(entry -> {
DriverNdiHomeClassificationResult homeResult = homeClassificationScope == null
? null
: homeClassificationScope.resultForDriver(entry.getKey());
DriverCountryTripSegmentationResult result = homeClassificationScope == null
? segmentDriverLegacy(entry.getKey(), entry.getValue(), budget)
: segmentDriverTrips(entry.getKey(), entry.getValue(), homeResult, budget);
driverResults.put(entry.getKey(), result);
scopeWarnings.addAll(result.warnings());
});
}
int segmentCount = driverResults.values().stream()
.mapToInt(DriverCountryTripSegmentationResult::segmentCount)
.sum();
int tripCount = driverResults.values().stream()
.mapToInt(DriverCountryTripSegmentationResult::tripCount)
.sum();
int cacheHitCount = driverResults.values().stream()
.mapToInt(DriverCountryTripSegmentationResult::reverseGeocodingCacheHitCount)
.sum();
int unresolvedCount = driverResults.values().stream()
.mapToInt(DriverCountryTripSegmentationResult::unresolvedCoordinateCount)
.sum();
List<String> notes = new ArrayList<>();
if (homeClassificationScope == null) {
notes.add("Country segmentation ran in compatibility mode over each complete driver timeline because no HOME-classification scope was supplied.");
} else {
notes.add("Built " + tripCount + " complete trip(s); each trip is bounded by consecutive HOME classifications and owns its contained NOT_HOME classifications and country segments.");
notes.add("Data before the first HOME classification and after the last HOME classification is not emitted as a complete trip.");
}
notes.add("Country segmentation preferred explicit tachograph border-crossing events and existing source country codes before Nominatim.");
notes.add("Nominatim remote lookups performed: " + budget.usedRemoteLookups()
+ " of configured maximum " + maxRemoteLookups + ".");
notes.add("Nominatim requests use a shared coordinate cache and the configured minimum request interval.");
return new DriverCountryTripSegmentationScopeResult(
driverResults.size(),
segmentCount,
budget.usedRemoteLookups(),
cacheHitCount,
unresolvedCount,
ATTRIBUTION,
driverResults,
notes,
distinctLimited(scopeWarnings, 50)
);
}
private DriverCountryTripSegmentationResult segmentDriverTrips(
String driverKey,
DriverWorkingTimePreparedInput preparedInput,
DriverNdiHomeClassificationResult homeResult,
LookupBudget budget
) {
DriverTimeline timeline = timeline(preparedInput);
List<String> warnings = new ArrayList<>();
TripWindowBuildResult windowBuild = buildTripWindows(
driverKey,
homeResult == null ? List.of() : homeResult.classifications(),
warnings
);
DriverStats stats = new DriverStats();
List<DriverClassifiedTrip> trips = new ArrayList<>();
List<DriverCountryTripSegment> flatSegments = new ArrayList<>();
int usedDrivingIntervalCount = 0;
for (TripWindow window : windowBuild.windows()) {
WindowSegmentation segmentation = segmentWindow(
driverKey,
window.tripId(),
window.startedAt(),
window.endedAt(),
timeline.drivingIntervals(),
timeline.supportEvents(),
budget,
stats,
warnings
);
usedDrivingIntervalCount += segmentation.drivingIntervalCount();
flatSegments.addAll(segmentation.segments());
trips.add(new DriverClassifiedTrip(
window.tripId(),
driverKey,
window.startedAt(),
window.endedAt(),
Duration.between(window.startedAt(), window.endedAt()).getSeconds(),
window.startHomeClassification(),
window.endHomeClassification(),
window.containedNonHomeClassifications(),
segmentation.drivingIntervalCount(),
segmentation.segments().size(),
segmentation.segments(),
List.of(
"Trip starts at the end of HOME interval " + window.startHomeClassification().intervalId() + ".",
"Trip ends at the start of HOME interval " + window.endHomeClassification().intervalId() + "."
)
));
}
if (budget.exhausted()) {
warnings.add("The configured Nominatim remote-lookup budget was exhausted; later uncached coordinates remained unresolved.");
}
List<String> notes = List.of(
"Built " + trips.size() + " complete trip(s) from "
+ (homeResult == null ? 0 : homeResult.classifications().size()) + " NDI classification(s).",
"Each HOME classification may close one trip and start the next trip.",
"Attached " + windowBuild.assignedNonHomeClassificationCount()
+ " NOT_HOME classification(s); " + windowBuild.unassignedNonHomeClassificationCount()
+ " remained outside complete HOME-to-HOME trips.",
"Explicit border crossings are authoritative; Nominatim is used only when a positioned event has no usable source country code."
);
return new DriverCountryTripSegmentationResult(
driverKey,
usedDrivingIntervalCount,
stats.supportingGeoEventCount,
stats.explicitBorderCrossingCount,
stats.remoteRequestCount,
stats.cacheHitCount,
stats.unresolvedCoordinateCount,
flatSegments.size(),
trips.size(),
windowBuild.unassignedNonHomeClassificationCount(),
ATTRIBUTION,
flatSegments,
trips,
notes,
distinctLimited(warnings, 30)
);
}
private DriverCountryTripSegmentationResult segmentDriverLegacy(
String driverKey,
DriverWorkingTimePreparedInput preparedInput,
LookupBudget budget
) {
DriverTimeline timeline = timeline(preparedInput);
if (timeline.drivingIntervals().isEmpty()) {
return new DriverCountryTripSegmentationResult(
driverKey,
0,
0,
0,
0,
0,
0,
0,
ATTRIBUTION,
List.of(),
List.of("No driving intervals were available for country trip segmentation."),
List.of()
);
}
DriverStats stats = new DriverStats();
List<String> warnings = new ArrayList<>();
OffsetDateTime start = timeline.drivingIntervals().getFirst().startedAt();
OffsetDateTime end = timeline.drivingIntervals().getLast().endedAt();
WindowSegmentation segmentation = segmentWindow(
driverKey,
null,
start,
end,
timeline.drivingIntervals(),
timeline.supportEvents(),
budget,
stats,
warnings
);
if (budget.exhausted()) {
warnings.add("The configured Nominatim remote-lookup budget was exhausted; later uncached coordinates remained unresolved.");
}
return new DriverCountryTripSegmentationResult(
driverKey,
segmentation.drivingIntervalCount(),
stats.supportingGeoEventCount,
stats.explicitBorderCrossingCount,
stats.remoteRequestCount,
stats.cacheHitCount,
stats.unresolvedCoordinateCount,
segmentation.segments().size(),
ATTRIBUTION,
segmentation.segments(),
List.of(
"Compatibility mode built flat country segments from the complete available driver timeline.",
"Country codes in the result are normalized to ISO 3166-1 alpha-2 where a mapping is known."
),
distinctLimited(warnings, 20)
);
}
private DriverTimeline timeline(DriverWorkingTimePreparedInput preparedInput) {
List<DriverWorkingTimeActivityInterval> drivingIntervals = preparedInput.processingInput()
.activityIntervals()
.stream()
.filter(Objects::nonNull)
.filter(interval -> "DRIVE".equalsIgnoreCase(interval.activityType()))
.filter(interval -> interval.startedAt() != null
&& interval.endedAt() != null
&& interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator
.comparing(DriverWorkingTimeActivityInterval::startedAt)
.thenComparing(DriverWorkingTimeActivityInterval::endedAt))
.toList();
List<RuntimeSupportEvidenceEvent> supportEvents = preparedInput.processingInput()
.supportEvidenceEvents()
.stream()
.filter(Objects::nonNull)
.filter(event -> event.occurredAt() != null)
.sorted(Comparator
.comparing(RuntimeSupportEvidenceEvent::occurredAt)
.thenComparing(event -> nullToEmpty(event.eventId())))
.toList();
return new DriverTimeline(drivingIntervals, supportEvents);
}
private TripWindowBuildResult buildTripWindows(
String driverKey,
List<DriverNdiHomeClassification> classifications,
List<String> warnings
) {
List<DriverNdiHomeClassification> ordered = classifications == null
? List.of()
: classifications.stream()
.filter(Objects::nonNull)
.filter(classification -> classification.evidence() != null)
.sorted(Comparator
.comparing(
(DriverNdiHomeClassification value) -> value.evidence().startedAt(),
Comparator.nullsLast(Comparator.naturalOrder())
)
.thenComparing(
value -> value.evidence().endedAt(),
Comparator.nullsLast(Comparator.naturalOrder())
)
.thenComparing(value -> nullToEmpty(value.intervalId())))
.toList();
List<TripWindow> windows = new ArrayList<>();
List<DriverNdiHomeClassification> pendingNonHome = new ArrayList<>();
DriverNdiHomeClassification startHome = null;
int unassignedNonHomeCount = 0;
int assignedNonHomeCount = 0;
for (DriverNdiHomeClassification classification : ordered) {
if (classification.status() != DriverNdiHomeStatus.HOME) {
if (startHome == null) {
unassignedNonHomeCount++;
} else {
pendingNonHome.add(classification);
}
continue;
}
if (startHome == null) {
startHome = classification;
pendingNonHome.clear();
continue;
}
OffsetDateTime tripStart = startHome.evidence().endedAt();
OffsetDateTime tripEnd = classification.evidence().startedAt();
if (tripStart == null || tripEnd == null || !tripEnd.isAfter(tripStart)) {
unassignedNonHomeCount += pendingNonHome.size();
warnings.add("Could not create HOME-to-HOME trip between intervals "
+ nullToEmpty(startHome.intervalId()) + " and " + nullToEmpty(classification.intervalId())
+ " because the active trip boundaries were missing or not increasing.");
} else {
List<DriverNdiHomeClassification> contained = pendingNonHome.stream()
.filter(value -> fullyContained(value, tripStart, tripEnd))
.toList();
assignedNonHomeCount += contained.size();
unassignedNonHomeCount += pendingNonHome.size() - contained.size();
String tripId = tripId(driverKey, startHome.intervalId(), classification.intervalId(), tripStart, tripEnd);
windows.add(new TripWindow(
tripId,
tripStart,
tripEnd,
startHome,
classification,
contained
));
}
startHome = classification;
pendingNonHome = new ArrayList<>();
}
unassignedNonHomeCount += pendingNonHome.size();
return new TripWindowBuildResult(
List.copyOf(windows),
assignedNonHomeCount,
unassignedNonHomeCount
);
}
private boolean fullyContained(
DriverNdiHomeClassification classification,
OffsetDateTime tripStart,
OffsetDateTime tripEnd
) {
if (classification == null || classification.evidence() == null) {
return false;
}
OffsetDateTime start = classification.evidence().startedAt();
OffsetDateTime end = classification.evidence().endedAt();
return start != null
&& end != null
&& !start.isBefore(tripStart)
&& !end.isAfter(tripEnd);
}
private WindowSegmentation segmentWindow(
String driverKey,
String tripId,
OffsetDateTime windowStart,
OffsetDateTime windowEnd,
List<DriverWorkingTimeActivityInterval> allDrivingIntervals,
List<RuntimeSupportEvidenceEvent> supportEvents,
LookupBudget budget,
DriverStats stats,
List<String> warnings
) {
if (windowStart == null || windowEnd == null || !windowEnd.isAfter(windowStart)) {
return new WindowSegmentation(0, List.of());
}
List<ClippedDrive> drivingIntervals = allDrivingIntervals.stream()
.filter(interval -> overlaps(interval.startedAt(), interval.endedAt(), windowStart, windowEnd))
.map(interval -> new ClippedDrive(
interval,
max(interval.startedAt(), windowStart),
min(interval.endedAt(), windowEnd)
))
.filter(interval -> interval.endedAt().isAfter(interval.startedAt()))
.sorted(Comparator
.comparing(ClippedDrive::startedAt)
.thenComparing(ClippedDrive::endedAt))
.toList();
if (drivingIntervals.isEmpty()) {
return new WindowSegmentation(0, List.of());
}
List<DriverCountryTripSegment> segments = new ArrayList<>();
ClippedDrive firstDrive = drivingIntervals.getFirst();
SegmentState state = new SegmentState(
firstDrive.startedAt(),
firstDrive.source().registrationKey(),
firstDrive.source().vehicleKey()
);
ClippedDrive previousDrive = null;
for (ClippedDrive drive : drivingIntervals) {
List<RuntimeSupportEvidenceEvent> driveEvents = supportEvents.stream()
.filter(event -> within(event.occurredAt(), drive.startedAt(), drive.endedAt()))
.filter(event -> compatibleVehicle(event, drive.source()))
.toList();
stats.supportingGeoEventCount += (int) driveEvents.stream()
.filter(this::hasCoordinateOrBorderEvidence)
.count();
stats.explicitBorderCrossingCount += (int) driveEvents.stream()
.filter(this::isExplicitBorderCrossing)
.count();
if (previousDrive != null && vehicleChanged(previousDrive.source(), drive.source())) {
String previousCountry = state.country;
addSegment(
segments,
tripId,
driverKey,
state,
previousDrive.endedAt(),
previousCountry,
previousCountry,
state.lastPositionEvent,
DriverCountryTripSegmentBoundarySource.VEHICLE_CHANGE,
null,
false
);
state = new SegmentState(
drive.startedAt(),
drive.source().registrationKey(),
drive.source().vehicleKey()
);
state.country = previousCountry;
}
for (RuntimeSupportEvidenceEvent event : driveEvents) {
if (!hasCoordinateOrBorderEvidence(event)) {
continue;
}
if (state.startLatitude == null && hasCoordinate(event)) {
state.setStartPosition(event);
}
if (isExplicitBorderCrossing(event)) {
processExplicitBorderCrossing(
segments,
tripId,
driverKey,
state,
event,
budget,
stats,
warnings
);
} else if (hasCoordinate(event)) {
processPosition(
segments,
tripId,
driverKey,
state,
event,
budget,
stats,
warnings
);
}
}
previousDrive = drive;
}
ClippedDrive lastDrive = drivingIntervals.getLast();
addSegment(
segments,
tripId,
driverKey,
state,
lastDrive.endedAt(),
state.country,
state.country,
state.lastPositionEvent,
DriverCountryTripSegmentBoundarySource.FINAL,
state.lastPositionEvent == null ? null : state.lastPositionEvent.eventId(),
false
);
return new WindowSegmentation(drivingIntervals.size(), List.copyOf(segments));
}
private void processExplicitBorderCrossing(
List<DriverCountryTripSegment> segments,
String tripId,
String driverKey,
SegmentState state,
RuntimeSupportEvidenceEvent event,
LookupBudget budget,
DriverStats stats,
List<String> warnings
) {
String countryFrom = CountryCodeNormalizer.normalizeTachograph(event.countryFrom());
String countryTo = CountryCodeNormalizer.normalizeTachograph(event.countryTo());
ResolvedEventCountry positionedCountry = null;
if (countryTo == null && hasCoordinate(event)) {
positionedCountry = resolveEventCountry(event, budget, stats, warnings);
countryTo = positionedCountry.countryCode();
}
if (state.country == null) {
state.country = firstNonBlank(countryFrom, countryTo);
}
String effectiveFrom = firstNonBlank(state.country, countryFrom);
String effectiveTo = firstNonBlank(countryTo, effectiveFrom);
if (effectiveFrom == null || effectiveTo == null || Objects.equals(effectiveFrom, effectiveTo)) {
state.lastPositionEvent = hasCoordinate(event) ? event : state.lastPositionEvent;
return;
}
addSegment(
segments,
tripId,
driverKey,
state,
event.occurredAt(),
effectiveFrom,
effectiveTo,
event,
DriverCountryTripSegmentBoundarySource.EXPLICIT_BORDER_CROSSING,
event.eventId(),
positionedCountry != null && positionedCountry.reverseGeocoded()
);
state.restartAt(event, effectiveTo);
}
private void processPosition(
List<DriverCountryTripSegment> segments,
String tripId,
String driverKey,
SegmentState state,
RuntimeSupportEvidenceEvent event,
LookupBudget budget,
DriverStats stats,
List<String> warnings
) {
ResolvedEventCountry resolved = resolveEventCountry(event, budget, stats, warnings);
state.lastPositionEvent = event;
if (resolved.countryCode() == null) {
return;
}
if (state.country == null) {
state.country = resolved.countryCode();
return;
}
if (Objects.equals(state.country, resolved.countryCode())) {
return;
}
DriverCountryTripSegmentBoundarySource source = resolved.reverseGeocoded()
? DriverCountryTripSegmentBoundarySource.NOMINATIM_COUNTRY_CHANGE
: DriverCountryTripSegmentBoundarySource.GNSS_SOURCE_COUNTRY_CHANGE;
addSegment(
segments,
tripId,
driverKey,
state,
event.occurredAt(),
state.country,
resolved.countryCode(),
event,
source,
event.eventId(),
resolved.reverseGeocoded()
);
state.restartAt(event, resolved.countryCode());
}
private ResolvedEventCountry resolveEventCountry(
RuntimeSupportEvidenceEvent event,
LookupBudget budget,
DriverStats stats,
List<String> warnings
) {
String sourceCountry = CountryCodeNormalizer.normalizeTachograph(event.countryCode());
if (sourceCountry != null) {
return new ResolvedEventCountry(sourceCountry, false);
}
if (!hasCoordinate(event)) {
return ResolvedEventCountry.unresolved();
}
GeoCountryResolution resolution = countryResolver.resolve(
event.latitude(),
event.longitude(),
budget.remoteLookupAllowed()
);
if (resolution.remoteRequestPerformed()) {
budget.recordRemoteLookup();
stats.remoteRequestCount++;
}
if (resolution.cacheHit()) {
stats.cacheHitCount++;
}
if (resolution.resolved()) {
return new ResolvedEventCountry(
CountryCodeNormalizer.normalizeIso(resolution.countryCode()),
true
);
}
stats.unresolvedCoordinateCount++;
if (resolution.status() == GeoCountryResolutionStatus.ERROR
|| resolution.status() == GeoCountryResolutionStatus.DISABLED
|| resolution.status() == GeoCountryResolutionStatus.REMOTE_LOOKUP_NOT_ALLOWED) {
warnings.add("Country could not be resolved for support event "
+ nullToEmpty(event.eventId()) + ": " + nullToEmpty(resolution.errorMessage()));
}
return ResolvedEventCountry.unresolved();
}
private void addSegment(
List<DriverCountryTripSegment> segments,
String tripId,
String driverKey,
SegmentState state,
OffsetDateTime endedAt,
String countryFrom,
String countryTo,
RuntimeSupportEvidenceEvent endPosition,
DriverCountryTripSegmentBoundarySource boundarySource,
String boundaryEventId,
boolean reverseGeocodedBoundary
) {
if (state.startedAt == null || endedAt == null || !endedAt.isAfter(state.startedAt)) {
return;
}
int index = segments.size();
String countryCode = firstNonBlank(countryFrom, countryTo);
segments.add(new DriverCountryTripSegment(
segmentId(driverKey, tripId, state.startedAt, endedAt, index),
tripId,
driverKey,
state.registrationKey,
state.vehicleKey,
state.startedAt,
endedAt,
countryCode,
countryFrom,
countryTo,
state.startLatitude,
state.startLongitude,
endPosition == null ? null : endPosition.latitude(),
endPosition == null ? null : endPosition.longitude(),
state.startPositionEventId,
endPosition == null ? null : endPosition.eventId(),
boundarySource,
boundaryEventId,
reverseGeocodedBoundary
));
}
private boolean overlaps(
OffsetDateTime intervalStart,
OffsetDateTime intervalEnd,
OffsetDateTime windowStart,
OffsetDateTime windowEnd
) {
return intervalStart != null
&& intervalEnd != null
&& intervalEnd.isAfter(windowStart)
&& intervalStart.isBefore(windowEnd);
}
private OffsetDateTime max(OffsetDateTime first, OffsetDateTime second) {
return first.isAfter(second) ? first : second;
}
private OffsetDateTime min(OffsetDateTime first, OffsetDateTime second) {
return first.isBefore(second) ? first : second;
}
private boolean within(OffsetDateTime value, OffsetDateTime start, OffsetDateTime end) {
return value != null
&& start != null
&& end != null
&& !value.isBefore(start)
&& !value.isAfter(end);
}
private boolean compatibleVehicle(
RuntimeSupportEvidenceEvent event,
DriverWorkingTimeActivityInterval interval
) {
if (event.vehicleKey() != null
&& interval.vehicleKey() != null
&& !event.vehicleKey().equals(interval.vehicleKey())) {
return false;
}
return event.registrationKey() == null
|| interval.registrationKey() == null
|| event.registrationKey().equals(interval.registrationKey());
}
private boolean vehicleChanged(
DriverWorkingTimeActivityInterval previous,
DriverWorkingTimeActivityInterval next
) {
if (previous.vehicleKey() != null && next.vehicleKey() != null) {
return !previous.vehicleKey().equals(next.vehicleKey());
}
if (previous.registrationKey() != null && next.registrationKey() != null) {
return !previous.registrationKey().equals(next.registrationKey());
}
return false;
}
private boolean hasCoordinateOrBorderEvidence(RuntimeSupportEvidenceEvent event) {
return hasCoordinate(event) || isExplicitBorderCrossing(event);
}
private boolean hasCoordinate(RuntimeSupportEvidenceEvent event) {
return event.latitude() != null && event.longitude() != null;
}
private boolean isExplicitBorderCrossing(RuntimeSupportEvidenceEvent event) {
return "BORDER_CROSSING".equalsIgnoreCase(event.eventDomain())
|| event.countryFrom() != null
|| event.countryTo() != null;
}
private String tripId(
String driverKey,
String startHomeIntervalId,
String endHomeIntervalId,
OffsetDateTime startedAt,
OffsetDateTime endedAt
) {
String raw = nullToEmpty(driverKey)
+ "|" + nullToEmpty(startHomeIntervalId)
+ "|" + nullToEmpty(endHomeIntervalId)
+ "|" + startedAt
+ "|" + endedAt;
return "DRIVER_TRIP|" + shortDigest(raw);
}
private String segmentId(
String driverKey,
String tripId,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
int index
) {
String raw = nullToEmpty(driverKey) + "|" + nullToEmpty(tripId)
+ "|" + startedAt + "|" + endedAt + "|" + index;
return "TRIP_COUNTRY|" + shortDigest(raw);
}
private String shortDigest(String raw) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(raw.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest, 0, 12);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is unavailable.", ex);
}
}
private String firstNonBlank(String... values) {
if (values == null) {
return null;
}
for (String value : values) {
if (value != null && !value.isBlank()) {
return value.trim().toUpperCase(Locale.ROOT);
}
}
return null;
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private List<String> distinctLimited(List<String> values, int limit) {
Set<String> distinct = new LinkedHashSet<>();
if (values != null) {
values.stream()
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.forEach(distinct::add);
}
return distinct.stream().limit(Math.max(0, limit)).toList();
}
private static final class SegmentState {
private OffsetDateTime startedAt;
private String registrationKey;
private String vehicleKey;
private String country;
private BigDecimal startLatitude;
private BigDecimal startLongitude;
private String startPositionEventId;
private RuntimeSupportEvidenceEvent lastPositionEvent;
private SegmentState(
OffsetDateTime startedAt,
String registrationKey,
String vehicleKey
) {
this.startedAt = startedAt;
this.registrationKey = registrationKey;
this.vehicleKey = vehicleKey;
}
private void setStartPosition(RuntimeSupportEvidenceEvent event) {
if (event == null || event.latitude() == null || event.longitude() == null) {
return;
}
this.startLatitude = event.latitude();
this.startLongitude = event.longitude();
this.startPositionEventId = event.eventId();
this.lastPositionEvent = event;
}
private void restartAt(RuntimeSupportEvidenceEvent event, String newCountry) {
this.startedAt = event.occurredAt();
this.country = newCountry;
this.startLatitude = event.latitude();
this.startLongitude = event.longitude();
this.startPositionEventId = event.eventId();
this.lastPositionEvent = event;
}
}
private static final class LookupBudget {
private final int maximum;
private int used;
private LookupBudget(int maximum) {
this.maximum = Math.max(0, maximum);
}
private boolean remoteLookupAllowed() {
return used < maximum;
}
private void recordRemoteLookup() {
used++;
}
private int usedRemoteLookups() {
return used;
}
private boolean exhausted() {
return maximum > 0 && used >= maximum;
}
}
private static final class DriverStats {
private int supportingGeoEventCount;
private int explicitBorderCrossingCount;
private int remoteRequestCount;
private int cacheHitCount;
private int unresolvedCoordinateCount;
}
private record ResolvedEventCountry(String countryCode, boolean reverseGeocoded) {
private static ResolvedEventCountry unresolved() {
return new ResolvedEventCountry(null, false);
}
}
private record DriverTimeline(
List<DriverWorkingTimeActivityInterval> drivingIntervals,
List<RuntimeSupportEvidenceEvent> supportEvents
) {
}
private record ClippedDrive(
DriverWorkingTimeActivityInterval source,
OffsetDateTime startedAt,
OffsetDateTime endedAt
) {
}
private record WindowSegmentation(
int drivingIntervalCount,
List<DriverCountryTripSegment> segments
) {
}
private record TripWindow(
String tripId,
OffsetDateTime startedAt,
OffsetDateTime endedAt,
DriverNdiHomeClassification startHomeClassification,
DriverNdiHomeClassification endHomeClassification,
List<DriverNdiHomeClassification> containedNonHomeClassifications
) {
}
private record TripWindowBuildResult(
List<TripWindow> windows,
int assignedNonHomeClassificationCount,
int unassignedNonHomeClassificationCount
) {
}
}

View File

@ -1,8 +1,11 @@
package at.procon.eventhub.processing.dto;
import at.procon.eventhub.processing.driverworkingtime.dto.DriverWorkingTimeProcessingResultDto;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationResult;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationResult;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
import at.procon.eventhub.processing.driverworkingtime.dto.DriverWorkingTimeProcessingResultDto;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.util.List;
public record UnifiedRuntimeDerivedProjectionResultDto(
@ -15,7 +18,11 @@ public record UnifiedRuntimeDerivedProjectionResultDto(
DriverWorkingTimeProcessingResultDto projection,
List<String> notes,
RuntimeSupportEvidenceNormalizationDebugDto supportEvidenceNormalization,
RuntimeDriverPartitionDebugDto partitionDebug
RuntimeDriverPartitionDebugDto partitionDebug,
@JsonInclude(JsonInclude.Include.NON_NULL)
DriverNdiHomeClassificationResult ndiHomeClassification,
@JsonInclude(JsonInclude.Include.NON_NULL)
DriverCountryTripSegmentationResult countryTripSegmentation
) {
public UnifiedRuntimeDerivedProjectionResultDto {
discoveredVehicles = discoveredVehicles == null ? List.of() : List.copyOf(discoveredVehicles);
@ -42,6 +49,8 @@ public record UnifiedRuntimeDerivedProjectionResultDto(
projection,
notes,
null,
null,
null,
null
);
}
@ -67,11 +76,39 @@ public record UnifiedRuntimeDerivedProjectionResultDto(
projection,
notes,
supportEvidenceNormalization,
null,
null,
null
);
}
public UnifiedRuntimeDerivedProjectionResultDto(
UnifiedRuntimeProcessingRequest request,
int driverSeedEventCount,
int discoveredVehicleCount,
int expandedVehicleEventCount,
int mergedEventCount,
List<UnifiedDiscoveredVehicleRef> discoveredVehicles,
DriverWorkingTimeProcessingResultDto projection,
List<String> notes,
RuntimeSupportEvidenceNormalizationDebugDto supportEvidenceNormalization,
RuntimeDriverPartitionDebugDto partitionDebug
) {
this(
request,
driverSeedEventCount,
discoveredVehicleCount,
expandedVehicleEventCount,
mergedEventCount,
discoveredVehicles,
projection,
notes,
supportEvidenceNormalization,
partitionDebug,
null,
null
);
}
public UnifiedRuntimeDerivedProjectionResultDto withPartitionDebug(RuntimeDriverPartitionDebugDto debug) {
return new UnifiedRuntimeDerivedProjectionResultDto(
@ -84,7 +121,9 @@ public record UnifiedRuntimeDerivedProjectionResultDto(
projection,
notes,
supportEvidenceNormalization,
debug
debug,
ndiHomeClassification,
countryTripSegmentation
);
}
}

View File

@ -0,0 +1,94 @@
package at.procon.eventhub.processing.eventprocessing.module;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.service.DriverCountryTripSegmentationService;
import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
@Component
public class DriverCountryTripSegmentationModule implements RuntimeProcessingModule {
private final DriverCountryTripSegmentationService segmentationService;
public DriverCountryTripSegmentationModule(
DriverCountryTripSegmentationService segmentationService
) {
this.segmentationService = segmentationService;
}
@Override
public String moduleKey() {
return DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION;
}
@Override
public RuntimeProcessingModuleDescriptorDto descriptor() {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Classified trips and country segmentation",
"Builds complete trips between consecutive HOME-classified NDIs, attaches the start/end HOME classifications and contained NOT_HOME classifications, then calculates country segments independently inside every trip.",
"JAVA+HTTP",
Set.of(
DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION,
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION
),
Set.of(
"Map<String, DriverWorkingTimePreparedInput>",
"DriverNdiHomeClassificationScopeResult"
),
Set.of("DriverCountryTripSegmentationScopeResult")
);
}
@Override
public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) {
DriverCountryTripSegmentationScopeResult result = segmentationService.segmentPreparedInputs(
preparedInputs(context),
homeClassificationScope(context)
);
int tripCount = result.driverResults().values().stream()
.mapToInt(driverResult -> driverResult.tripCount())
.sum();
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("driverCount", result.driverCount());
metadata.put("tripCount", tripCount);
metadata.put("segmentCount", result.segmentCount());
metadata.put("reverseGeocodingRemoteRequestCount", result.reverseGeocodingRemoteRequestCount());
metadata.put("reverseGeocodingCacheHitCount", result.reverseGeocodingCacheHitCount());
metadata.put("unresolvedCoordinateCount", result.unresolvedCoordinateCount());
metadata.put("reverseGeocodingAttribution", result.reverseGeocodingAttribution());
return new RuntimeProcessingModuleResult(
moduleKey(),
RuntimeProcessingModuleStatus.SUCCESS,
result,
metadata,
result.warnings()
);
}
private DriverNdiHomeClassificationScopeResult homeClassificationScope(
RuntimeProcessingModuleContext context
) {
Object output = context.requireResult(DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION).output();
if (output instanceof DriverNdiHomeClassificationScopeResult result) {
return result;
}
throw new IllegalStateException("Module " + moduleKey()
+ " requires DriverNdiHomeClassificationScopeResult from "
+ DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION + ".");
}
@SuppressWarnings("unchecked")
private Map<String, DriverWorkingTimePreparedInput> preparedInputs(RuntimeProcessingModuleContext context) {
Object output = context.requireResult(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION).output();
if (output instanceof Map<?, ?> map) {
return (Map<String, DriverWorkingTimePreparedInput>) map;
}
return Map.of();
}
}

View File

@ -1,6 +1,8 @@
package at.procon.eventhub.processing.eventprocessing.module;
import at.procon.eventhub.processing.driverworkingtime.dto.DriverWorkingTimeProcessingResultDto;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDto;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDriverWorkingTimeScopeResultDto;
@ -65,15 +67,16 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"Driving-derived projections",
"Executes the shared driver working-time core from typed per-driver module outputs for driving interruptions, rest candidates, card-absence coverage, overnight candidates, and trip candidates.",
"Executes the shared driver working-time core from typed per-driver module outputs for driving interruptions, rest candidates, card-absence coverage, overnight candidates, and trip candidates; optional NDI classifications and HOME-to-HOME trip/country-segmentation results are attached when present.",
"ESPER+JAVA",
Set.of(
DriverWorkingTimeModuleKeys.EVENT_TO_ACTIVITY_INTERVALS,
DriverWorkingTimeModuleKeys.VEHICLE_USAGE_MERGE,
DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION
),
Set.of("DriverActivityIntervalEvent", "DriverWorkingTimeVehicleUsageInterval", "Map<String, DriverWorkingTimePreparedInput>"),
Set.of("UnifiedRuntimeDriverWorkingTimeScopeResultDto")
Set.of("DriverActivityIntervalEvent", "DriverWorkingTimeVehicleUsageInterval",
"Map<String, DriverWorkingTimePreparedInput>"),
Set.of("UnifiedRuntimeDriverWorkingTimeScopeResultDto", "DriverNdiHomeClassificationResult", "DriverCountryTripSegmentationResult")
);
}
@ -86,6 +89,10 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess
UnifiedRuntimeEventBundle broadBundle = runtimeEventBundle(context);
UnifiedRuntimeProcessingApiRequest scopeRequest = scopeRequest(context);
Map<String, DriverWorkingTimePreparedInput> preparedInputs = preparedInputs(context);
DriverNdiHomeClassificationScopeResult ndiHomeClassificationScope =
optionalNdiHomeClassificationScope(context);
DriverCountryTripSegmentationScopeResult countryTripSegmentationScope =
optionalCountryTripSegmentationScope(context);
LinkedHashMap<String, UnifiedRuntimeDerivedProjectionResultDto> driverResults = new LinkedHashMap<>();
List<String> warnings = new ArrayList<>();
@ -119,13 +126,26 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess
projection,
projection.notes(),
preparedInput.partition().supportEvidenceNormalization(),
preparedInput.partition().partitionDebug()
preparedInput.partition().partitionDebug(),
ndiHomeClassificationScope == null
? null
: ndiHomeClassificationScope.resultForDriver(preparedInput.driverKey()),
countryTripSegmentationScope == null
? null
: countryTripSegmentationScope.resultForDriver(preparedInput.driverKey())
));
}
List<String> notes = new ArrayList<>(broadBundle.notes());
notes.add("Runtime driver working-time processing used module-to-module dataflow for event assembly, activity intervalization, vehicle-usage intervalization, evidence attachment, support evidence normalization, and final derived projections.");
notes.add("Selected driver partitions: " + driverResults.size() + ".");
if (ndiHomeClassificationScope != null) {
notes.addAll(ndiHomeClassificationScope.notes());
}
if (countryTripSegmentationScope != null) {
notes.addAll(countryTripSegmentationScope.notes());
warnings.addAll(countryTripSegmentationScope.warnings());
}
UnifiedRuntimeDriverWorkingTimeScopeResultDto result = new UnifiedRuntimeDriverWorkingTimeScopeResultDto(
broadBundle.request(),
@ -194,6 +214,28 @@ public class DriverWorkingTimeDerivedProjectionsModule implements RuntimeProcess
+ " requires previous result " + DriverWorkingTimeModuleKeys.RUNTIME_EVENT_ASSEMBLY + ".");
}
private DriverNdiHomeClassificationScopeResult optionalNdiHomeClassificationScope(
RuntimeProcessingModuleContext context
) {
RuntimeProcessingModuleResult result =
context.previousResults().get(DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION);
if (result != null && result.output() instanceof DriverNdiHomeClassificationScopeResult scopeResult) {
return scopeResult;
}
return null;
}
private DriverCountryTripSegmentationScopeResult optionalCountryTripSegmentationScope(
RuntimeProcessingModuleContext context
) {
RuntimeProcessingModuleResult result =
context.previousResults().get(DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION);
if (result != null && result.output() instanceof DriverCountryTripSegmentationScopeResult scopeResult) {
return scopeResult;
}
return null;
}
private UnifiedRuntimeProcessingApiRequest scopeRequest(RuntimeProcessingModuleContext context) {
Object value = context.attributes().get("runtimeScopeApiRequest");
if (value instanceof UnifiedRuntimeProcessingApiRequest request) {

View File

@ -10,6 +10,8 @@ public final class DriverWorkingTimeModuleKeys {
public static final String VEHICLE_USAGE_MERGE = "vehicle-usage-merge";
public static final String VEHICLE_EVIDENCE_ATTACHMENT = "vehicle-evidence-attachment";
public static final String SUPPORT_EVIDENCE_NORMALIZATION = "support-evidence-normalization";
public static final String NDI_HOME_CLASSIFICATION = "ndi-home-classification";
public static final String COUNTRY_TRIP_SEGMENTATION = "country-trip-segmentation";
public static final String DRIVING_DERIVED_PROJECTIONS = "driving-derived-projections";
private DriverWorkingTimeModuleKeys() {

View File

@ -0,0 +1,79 @@
package at.procon.eventhub.processing.eventprocessing.module;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.service.DriverNdiHomeClassificationService;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.eventprocessing.plan.RuntimeProcessingModuleDescriptorDto;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
@Component
public class NdiHomeClassificationModule implements RuntimeProcessingModule {
private final DriverNdiHomeClassificationService classificationService;
public NdiHomeClassificationModule(DriverNdiHomeClassificationService classificationService) {
this.classificationService = classificationService;
}
@Override
public String moduleKey() {
return DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION;
}
@Override
public RuntimeProcessingModuleDescriptorDto descriptor() {
return new RuntimeProcessingModuleDescriptorDto(
moduleKey(),
"NDI home classification",
"Builds enriched non-driving intervals, accumulates long-rest locations in a driver-aware cache, applies Haversine DBSCAN, and classifies HOME/NOT_HOME using the ordered NDI rules.",
"ESPER+JAVA",
Set.of(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION),
Set.of("Map<String, DriverWorkingTimePreparedInput>"),
Set.of("DriverNdiHomeClassificationScopeResult")
);
}
@Override
public RuntimeProcessingModuleResult execute(RuntimeProcessingModuleContext context) {
Map<String, DriverWorkingTimePreparedInput> preparedInputs = preparedInputs(context);
UnifiedRuntimeProcessingApiRequest scopeRequest = scopeRequest(context);
DriverNdiHomeClassificationScopeResult result = classificationService.classifyPreparedInputs(
scopeRequest,
preparedInputs
);
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("corpusKey", result.corpusKey());
metadata.put("currentObservationCount", result.currentObservationCount());
metadata.put("cachedObservationCount", result.cachedObservationCount());
metadata.put("clusterCount", result.clusterCount());
metadata.put("driverResultCount", result.driverResults().size());
return new RuntimeProcessingModuleResult(
moduleKey(),
RuntimeProcessingModuleStatus.SUCCESS,
result,
metadata,
java.util.List.of()
);
}
private UnifiedRuntimeProcessingApiRequest scopeRequest(RuntimeProcessingModuleContext context) {
Object value = context.attributes().get("runtimeScopeApiRequest");
if (value instanceof UnifiedRuntimeProcessingApiRequest request) {
return request;
}
return context.request().sourceSelection();
}
@SuppressWarnings("unchecked")
private Map<String, DriverWorkingTimePreparedInput> preparedInputs(RuntimeProcessingModuleContext context) {
Object output = context.requireResult(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION).output();
if (output instanceof Map<?, ?> map) {
return (Map<String, DriverWorkingTimePreparedInput>) map;
}
return Map.of();
}
}

View File

@ -0,0 +1,122 @@
package at.procon.eventhub.processing.eventprocessing.plan;
import at.procon.eventhub.processing.eventprocessing.module.DriverWorkingTimeModuleKeys;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Component;
/**
* Dedicated public processing-plan entry point for NDI HOME/NOT_HOME classification.
*
* <p>The implementation deliberately delegates to the shared driver-working-time pipeline so
* activity intervalization, vehicle-usage reconciliation, support evidence normalization, and
* reusable NDI enrichment keep one source-neutral implementation.</p>
*/
@Component
public class DriverHomeClassificationRuntimeProcessingPlan implements RuntimeProcessingPlan {
public static final String PLAN_KEY = "driver-home-classification-v1";
private final DriverWorkingTimeRuntimeProcessingPlan delegate;
public DriverHomeClassificationRuntimeProcessingPlan(DriverWorkingTimeRuntimeProcessingPlan delegate) {
this.delegate = delegate;
}
@Override
public String processingPlanKey() {
return PLAN_KEY;
}
@Override
public RuntimeEventPartitioningStrategy defaultPartitioningStrategy() {
return delegate.defaultPartitioningStrategy();
}
@Override
public String displayName() {
return "Driver NDI home classification";
}
@Override
public String description() {
return "Builds enriched non-driving intervals, learns company and driver home locations, applies ordered HOME/NOT_HOME rules, creates complete trips between consecutive HOME classifications, attaches contained NOT_HOME classifications, and calculates per-trip country segments using explicit border events plus Nominatim-backed GNSS country resolution.";
}
@Override
public List<RuntimeEventPartitioningStrategy> supportedPartitioningStrategies() {
return delegate.supportedPartitioningStrategies();
}
@Override
public List<RuntimeProcessingModuleDescriptorDto> modules() {
return delegate.modules();
}
@Override
public Set<String> requiredParameters() {
return delegate.requiredParameters();
}
@Override
public Set<String> optionalParameters() {
return delegate.optionalParameters();
}
@Override
public RuntimeProcessingExecutionResultDto execute(RuntimeProcessingExecutionApiRequest request) {
RuntimeProcessingExecutionApiRequest delegatedRequest = prepareDelegatedRequest(request);
RuntimeProcessingExecutionResultDto result = delegate.execute(delegatedRequest);
return new RuntimeProcessingExecutionResultDto(
PLAN_KEY,
result.executedModules(),
result.partitioningStrategy(),
result.request(),
result.inputEventCount(),
result.selectedPartitionCount(),
result.discoveredVehicleCount(),
result.discoveredVehicles(),
result.moduleResults(),
result.partitionResults(),
result.notes(),
result.warnings()
);
}
RuntimeProcessingExecutionApiRequest prepareDelegatedRequest(RuntimeProcessingExecutionApiRequest request) {
List<String> modules = new ArrayList<>();
if (request.modules() != null) {
request.modules().stream()
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.forEach(modules::add);
}
modules.removeIf(moduleKey -> DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION.equals(moduleKey)
|| DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION.equals(moduleKey)
|| DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS.equals(moduleKey));
modules.add(DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION);
modules.add(DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION);
modules.add(DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS);
Map<String, Object> parameters = new LinkedHashMap<>();
if (request.parameters() != null) {
parameters.putAll(request.parameters());
}
parameters.putIfAbsent(
DriverWorkingTimeRuntimeProcessingPlan.NDI_LEARN_ALL_FILE_SESSION_DRIVERS_PARAMETER,
true
);
return new RuntimeProcessingExecutionApiRequest(
DriverWorkingTimeRuntimeProcessingPlan.PLAN_KEY,
request.sourceSelection(),
request.partitioning(),
List.copyOf(modules),
parameters
);
}
}

View File

@ -20,6 +20,7 @@ import at.procon.eventhub.processing.eventprocessing.module.SupportEvidenceNorma
import at.procon.eventhub.processing.eventprocessing.module.DriverWorkingTimeDerivedProjectionsModule;
import at.procon.eventhub.processing.service.RuntimeDriverWorkingTimeScopeProcessingService;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.model.UnifiedDiscoveredVehicleRef;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -37,6 +38,7 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
public static final String INCLUDE_PARTITION_METADATA_PARAMETER = "includePartitionMetadata";
public static final String INCLUDE_PARTITION_MODULE_RESULTS_PARAMETER = "includePartitionModuleResults";
public static final String INCLUDE_SUPPORT_EVIDENCE_NORMALIZATION_PARAMETER = "includeSupportEvidenceNormalization";
public static final String NDI_LEARN_ALL_FILE_SESSION_DRIVERS_PARAMETER = "ndiLearnAllFileSessionDrivers";
private final RuntimeProcessingPipelineExecutor pipelineExecutor;
private final boolean includeRuntimeEventAssemblyModule;
@ -194,10 +196,28 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
Set.of("Map<String, DriverWorkingTimeDriverPartition>", "DriverActivityIntervalEvent"),
Set.of("RuntimeSupportEvidenceNormalizationDebugDto")
),
new RuntimeProcessingModuleDescriptorDto(
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION,
"NDI home classification",
"Builds enriched non-driving intervals, accumulates long-rest locations across file sessions, clusters them with Haversine DBSCAN, and applies ordered HOME/NOT_HOME rules.",
"ESPER+JAVA",
Set.of(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION),
Set.of("Map<String, DriverWorkingTimePreparedInput>"),
Set.of("DriverNdiHomeClassificationScopeResult")
),
new RuntimeProcessingModuleDescriptorDto(
DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION,
"Country trip segmentation",
"Builds country trip segments from explicit border crossings and GNSS country changes; missing countries are resolved through cached, rate-limited Nominatim reverse geocoding.",
"JAVA+HTTP",
Set.of(DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION),
Set.of("Map<String, DriverWorkingTimePreparedInput>"),
Set.of("DriverCountryTripSegmentationScopeResult")
),
new RuntimeProcessingModuleDescriptorDto(
DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS,
"Driving-derived projections",
"Runs the shared driver working-time derived projection module for driving interruptions, rest candidates, trips, and overnight candidates.",
"Runs the shared driver working-time derived projection module for driving interruptions, rest candidates, trips, and overnight candidates; attaches optional NDI HOME/NOT_HOME and country-trip results when requested.",
"ESPER+JAVA",
Set.of(
DriverWorkingTimeModuleKeys.EVENT_TO_ACTIVITY_INTERVALS,
@ -205,9 +225,14 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
DriverWorkingTimeModuleKeys.SUPPORT_EVIDENCE_NORMALIZATION
),
Set.of("DriverActivityIntervalEvent", "DriverWorkingTimeVehicleUsageInterval", "Map<String, DriverWorkingTimePreparedInput>"),
Set.of("DriverWorkingTimeProcessingResultDto")
Set.of("DriverWorkingTimeProcessingResultDto", "DriverNdiHomeClassificationResult", "DriverCountryTripSegmentationResult")
)
));
if (!includeRuntimeEventAssemblyModule) {
descriptors.removeIf(descriptor -> DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION
.equals(descriptor.moduleKey())
|| DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION.equals(descriptor.moduleKey()));
}
return List.copyOf(descriptors);
}
@ -225,6 +250,7 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
"includeDrivingIntervals",
"includePartitionDebug",
INCLUDE_SUPPORT_EVIDENCE_NORMALIZATION_PARAMETER,
NDI_LEARN_ALL_FILE_SESSION_DRIVERS_PARAMETER,
"eventMixingMode"
);
}
@ -266,11 +292,24 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
request.partitioning(),
request.parameters()
);
UnifiedRuntimeProcessingApiRequest scopeRequest = applyExecutionRequest(
UnifiedRuntimeProcessingApiRequest requestedScopeRequest = applyExecutionRequest(
request.sourceSelection(),
request.partitioning(),
request.parameters()
);
boolean ndiHomeClassificationRequested = requestsNdiHomeClassification(request.modules());
boolean learnAllFileSessionDrivers = booleanParameter(
request.parameters(),
NDI_LEARN_ALL_FILE_SESSION_DRIVERS_PARAMETER,
false
);
UnifiedRuntimeProcessingApiRequest scopeRequest = expandFileSessionLearningScope(
requestedScopeRequest,
ndiHomeClassificationRequested && learnAllFileSessionDrivers
);
Set<String> requestedOutputDriverKeys = requestedOutputDriverKeys(requestedScopeRequest);
boolean filterOutputDrivers = !Boolean.TRUE.equals(requestedScopeRequest.includeAllDrivers())
&& !requestedOutputDriverKeys.isEmpty();
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("runtimeScopeApiRequest", scopeRequest);
@ -295,6 +334,9 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults = new LinkedHashMap<>();
workingTimeResult.driverResults().forEach((driverKey, driverResult) -> {
if (filterOutputDrivers && !requestedOutputDriverKeys.contains(driverKey)) {
return;
}
UnifiedRuntimeDerivedProjectionResultDto shapedDriverResult = shapeDriverResult(
driverResult,
includeSupportEvidenceNormalization,
@ -316,18 +358,31 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
);
});
boolean learningScopeExpanded = !scopeRequest.equals(requestedScopeRequest);
List<String> resultNotes = new java.util.ArrayList<>(workingTimeResult.notes());
if (learningScopeExpanded) {
resultNotes.add("NDI location learning expanded the selected tachograph file sessions to all drivers; "
+ "the response remains filtered to the originally requested driver partition(s).");
}
List<UnifiedDiscoveredVehicleRef> responseVehicles = learningScopeExpanded
? selectedDiscoveredVehicles(partitionResults)
: workingTimeResult.discoveredVehicles();
int responseInputEventCount = learningScopeExpanded
? selectedInputEventCount(partitionResults)
: workingTimeResult.inputEventCount();
return new RuntimeProcessingExecutionResultDto(
processingPlanKey(),
executedModules,
RuntimeEventPartitioningStrategy.DRIVER,
workingTimeResult.request(),
workingTimeResult.inputEventCount(),
workingTimeResult.selectedDriverCount(),
workingTimeResult.discoveredVehicleCount(),
workingTimeResult.discoveredVehicles(),
requestedScopeRequest.toRuntimeRequest(),
responseInputEventCount,
partitionResults.size(),
responseVehicles.size(),
responseVehicles,
includeExecutionModuleResults ? sanitizeExecutionModuleResults(moduleResults) : Map.of(),
partitionResults,
workingTimeResult.notes(),
resultNotes,
workingTimeResult.warnings()
);
}
@ -352,6 +407,45 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
+ (output == null ? "null" : output.getClass().getName()));
}
private List<UnifiedDiscoveredVehicleRef> selectedDiscoveredVehicles(
Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults
) {
List<UnifiedDiscoveredVehicleRef> vehicles = new java.util.ArrayList<>();
for (RuntimeEventProcessingPartitionResultDto partition : partitionResults.values()) {
if (!(partition.result() instanceof UnifiedRuntimeDerivedProjectionResultDto driverResult)) {
continue;
}
for (UnifiedDiscoveredVehicleRef candidate : driverResult.discoveredVehicles()) {
boolean merged = false;
for (int index = 0; index < vehicles.size(); index++) {
UnifiedDiscoveredVehicleRef existing = vehicles.get(index);
if (existing.matches(candidate)) {
vehicles.set(index, existing.merge(candidate));
merged = true;
break;
}
}
if (!merged) {
vehicles.add(candidate);
}
}
}
vehicles.sort(java.util.Comparator.comparing(UnifiedDiscoveredVehicleRef::stableKey));
return List.copyOf(vehicles);
}
private int selectedInputEventCount(
Map<String, RuntimeEventProcessingPartitionResultDto> partitionResults
) {
long count = partitionResults.values().stream()
.map(RuntimeEventProcessingPartitionResultDto::result)
.filter(UnifiedRuntimeDerivedProjectionResultDto.class::isInstance)
.map(UnifiedRuntimeDerivedProjectionResultDto.class::cast)
.mapToLong(UnifiedRuntimeDerivedProjectionResultDto::mergedEventCount)
.sum();
return (int) Math.min(Integer.MAX_VALUE, Math.max(0L, count));
}
private Map<String, RuntimeProcessingModuleResult> sanitizeExecutionModuleResults(
Map<String, RuntimeProcessingModuleResult> moduleResults
) {
@ -403,6 +497,44 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
List.of()
)
);
if (driverResult.ndiHomeClassification() != null) {
results.put(
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION,
new RuntimeProcessingModuleResult(
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION,
RuntimeProcessingModuleStatus.SUCCESS,
driverResult.ndiHomeClassification(),
Map.of(
"nonDrivingIntervalCount", driverResult.ndiHomeClassification().nonDrivingIntervalCount(),
"cachedActualDriverObservationCount", driverResult.ndiHomeClassification().cachedActualDriverObservationCount(),
"cachedOtherDriverObservationCount", driverResult.ndiHomeClassification().cachedOtherDriverObservationCount(),
"companyHomeClusterCount", driverResult.ndiHomeClassification().companyHomeClusterCount(),
"driverHomeClusterCount", driverResult.ndiHomeClassification().driverHomeClusterCount()
),
List.of()
)
);
}
if (driverResult.countryTripSegmentation() != null) {
results.put(
DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION,
new RuntimeProcessingModuleResult(
DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION,
RuntimeProcessingModuleStatus.SUCCESS,
driverResult.countryTripSegmentation(),
Map.of(
"tripCount", driverResult.countryTripSegmentation().tripCount(),
"segmentCount", driverResult.countryTripSegmentation().segmentCount(),
"unassignedNonHomeClassificationCount", driverResult.countryTripSegmentation().unassignedNonHomeClassificationCount(),
"explicitBorderCrossingCount", driverResult.countryTripSegmentation().explicitBorderCrossingCount(),
"reverseGeocodingRemoteRequestCount", driverResult.countryTripSegmentation().reverseGeocodingRemoteRequestCount(),
"reverseGeocodingCacheHitCount", driverResult.countryTripSegmentation().reverseGeocodingCacheHitCount(),
"unresolvedCoordinateCount", driverResult.countryTripSegmentation().unresolvedCoordinateCount()
),
driverResult.countryTripSegmentation().warnings()
)
);
}
results.put(
DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS,
new RuntimeProcessingModuleResult(
@ -433,6 +565,27 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
if (driverResult.partitionDebug() != null) {
metadata.put("partitionDebug", driverResult.partitionDebug());
}
if (driverResult.ndiHomeClassification() != null) {
metadata.put("ndiHomeClassification", Map.of(
"nonDrivingIntervalCount", driverResult.ndiHomeClassification().nonDrivingIntervalCount(),
"currentLongLocationObservationCount", driverResult.ndiHomeClassification().currentLongLocationObservationCount(),
"cachedActualDriverObservationCount", driverResult.ndiHomeClassification().cachedActualDriverObservationCount(),
"cachedOtherDriverObservationCount", driverResult.ndiHomeClassification().cachedOtherDriverObservationCount(),
"companyHomeClusterCount", driverResult.ndiHomeClassification().companyHomeClusterCount(),
"driverHomeClusterCount", driverResult.ndiHomeClassification().driverHomeClusterCount()
));
}
if (driverResult.countryTripSegmentation() != null) {
metadata.put("countryTripSegmentation", Map.of(
"tripCount", driverResult.countryTripSegmentation().tripCount(),
"segmentCount", driverResult.countryTripSegmentation().segmentCount(),
"unassignedNonHomeClassificationCount", driverResult.countryTripSegmentation().unassignedNonHomeClassificationCount(),
"explicitBorderCrossingCount", driverResult.countryTripSegmentation().explicitBorderCrossingCount(),
"reverseGeocodingRemoteRequestCount", driverResult.countryTripSegmentation().reverseGeocodingRemoteRequestCount(),
"reverseGeocodingCacheHitCount", driverResult.countryTripSegmentation().reverseGeocodingCacheHitCount(),
"unresolvedCoordinateCount", driverResult.countryTripSegmentation().unresolvedCoordinateCount()
));
}
return metadata;
}
@ -451,10 +604,90 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
driverResult.projection(),
driverResult.notes(),
includeSupportEvidenceNormalization ? driverResult.supportEvidenceNormalization() : null,
includePartitionDebug ? driverResult.partitionDebug() : null
includePartitionDebug ? driverResult.partitionDebug() : null,
driverResult.ndiHomeClassification(),
driverResult.countryTripSegmentation()
);
}
UnifiedRuntimeProcessingApiRequest expandFileSessionLearningScope(
UnifiedRuntimeProcessingApiRequest request,
boolean enabled
) {
if (!enabled || !includeRuntimeEventAssemblyModule || request == null || !isFileSessionOnly(request)) {
return request;
}
Set<String> explicitDriverKeys = requestedOutputDriverKeys(request);
if (Boolean.TRUE.equals(request.includeAllDrivers()) && explicitDriverKeys.isEmpty()) {
return request;
}
// Without explicit canonical driver keys the response cannot be filtered safely after
// broadening the internal learning scope. Keep alternate card/source selectors unchanged.
if (explicitDriverKeys.isEmpty()) {
return request;
}
return new UnifiedRuntimeProcessingApiRequest(
request.sessionId(),
request.sessionIds(),
request.compositeSessionId(),
request.tenantKey(),
request.sourceFamilies(),
request.eventBackend(),
request.sourceKinds(),
null,
Set.of(),
true,
request.vehicleKeys(),
request.includeAllVehicles(),
null,
null,
null,
request.occurredFrom(),
request.occurredTo(),
request.expandVehicleEvents(),
request.vehicleExpansionPaddingMinutes(),
request.includeIntersectingIntervals(),
request.significantDrivingMinutes(),
request.minimumRestPeriodMinutes(),
request.includeActivityIntervals(),
request.includeDrivingIntervals(),
request.sourceInputs()
);
}
private boolean isFileSessionOnly(UnifiedRuntimeProcessingApiRequest request) {
if (request.sourceInputs() != null && !request.sourceInputs().isEmpty()) {
List<at.procon.eventhub.processing.dto.UnifiedRuntimeSourceInputApiRequest> sourceInputs =
request.sourceInputs().stream().filter(java.util.Objects::nonNull).toList();
return !sourceInputs.isEmpty() && sourceInputs.stream().allMatch(input -> input.sourceFamily()
== at.procon.eventhub.processing.model.UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION);
}
if (request.sourceFamilies() != null && !request.sourceFamilies().isEmpty()) {
return request.sourceFamilies().stream().allMatch(sourceFamily -> sourceFamily
== at.procon.eventhub.processing.model.UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION);
}
return request.sessionId() != null
|| (request.sessionIds() != null && !request.sessionIds().isEmpty())
|| request.compositeSessionId() != null;
}
private Set<String> requestedOutputDriverKeys(UnifiedRuntimeProcessingApiRequest request) {
java.util.LinkedHashSet<String> result = new java.util.LinkedHashSet<>();
if (request == null) {
return Set.of();
}
if (request.driverKeys() != null) {
request.driverKeys().stream()
.filter(value -> value != null && !value.isBlank())
.map(String::trim)
.forEach(result::add);
}
if (request.driverKey() != null && !request.driverKey().isBlank()) {
result.add(request.driverKey().trim());
}
return Set.copyOf(result);
}
public UnifiedRuntimeProcessingApiRequest applyExecutionRequest(
UnifiedRuntimeProcessingApiRequest sourceSelection,
RuntimeEventPartitioningApiRequest partitioning,
@ -567,15 +800,46 @@ public class DriverWorkingTimeRuntimeProcessingPlan implements RuntimeProcessing
requested.put(module.trim(), module.trim());
}
}
requested.putIfAbsent(DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS,
DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS);
boolean includeNdiHomeClassification =
requested.remove(DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION) != null;
boolean includeCountryTripSegmentation =
requested.remove(DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION) != null;
requested.remove(DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS);
if (includeNdiHomeClassification) {
requested.put(
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION,
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION
);
}
if (includeCountryTripSegmentation) {
requested.put(
DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION,
DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION
);
}
requested.put(
DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS,
DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS
);
return List.copyOf(requested.values());
}
return modules().stream()
.map(RuntimeProcessingModuleDescriptorDto::moduleKey)
.filter(moduleKey -> !DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION.equals(moduleKey))
.filter(moduleKey -> !DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION.equals(moduleKey))
.toList();
}
private boolean requestsNdiHomeClassification(List<String> requestedModules) {
if (requestedModules == null) {
return false;
}
return requestedModules.stream()
.filter(java.util.Objects::nonNull)
.map(String::trim)
.anyMatch(DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION::equals);
}
private boolean booleanParameter(Map<String, Object> parameters, String key, boolean fallback) {
if (parameters == null || !parameters.containsKey(key)) {
return fallback;

View File

@ -9,6 +9,7 @@ import at.procon.eventhub.dto.EventType;
import at.procon.eventhub.dto.GeoPointDto;
import at.procon.eventhub.dto.VehicleRefDto;
import at.procon.eventhub.processing.support.RuntimeEntityReferenceResolver;
import at.procon.eventhub.reference.CountryCodeNormalizer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
@ -87,10 +88,12 @@ public class RuntimeSupportEvidenceNormalizer {
BigDecimal latitude = position == null ? decimal(raw, "latitude") : position.latitude();
BigDecimal longitude = position == null ? decimal(raw, "longitude") : position.longitude();
Long odometerKm = firstNonNull(longValue(raw, "odometerKm"), toKilometers(event.odometerM()));
String sourceFamily = sourceFamily(event);
String sourceKind = firstNonBlank(text(raw, "sourceKind"), sourceKind(event));
return new RuntimeSupportEvidenceEvent(
firstNonBlank(text(raw, "supportEventId"), text(raw, "sourceRowId"), event.externalSourceEventId()),
sourceFamily(event),
firstNonBlank(text(raw, "sourceKind"), sourceKind(event)),
sourceFamily,
sourceKind,
event.eventDomain() == null ? null : event.eventDomain().name(),
event.eventType() == null ? null : event.eventType().name(),
event.lifecycle() == null ? null : event.lifecycle().name(),
@ -101,10 +104,22 @@ public class RuntimeSupportEvidenceNormalizer {
event.occurredAt() == null ? null : event.occurredAt().toEpochSecond(),
latitude,
longitude,
firstNonBlank(text(raw, "country"), detailText(event, "country")),
CountryCodeNormalizer.normalizeSupportEvent(
sourceFamily,
sourceKind,
firstNonBlank(text(raw, "country"), detailText(event, "country"))
),
firstNonBlank(text(raw, "region"), detailText(event, "region")),
firstNonBlank(text(raw, "countryFrom"), detailText(event, "countryFrom")),
firstNonBlank(text(raw, "countryTo"), detailText(event, "countryTo")),
CountryCodeNormalizer.normalizeSupportEvent(
sourceFamily,
sourceKind,
firstNonBlank(text(raw, "countryFrom"), detailText(event, "countryFrom"))
),
CountryCodeNormalizer.normalizeSupportEvent(
sourceFamily,
sourceKind,
firstNonBlank(text(raw, "countryTo"), detailText(event, "countryTo"))
),
firstNonBlank(text(raw, "operation"), detailText(event, "operation")),
odometerKm,
decimal(raw, "avgSpeedKmh"),

View File

@ -6,6 +6,7 @@ import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvide
import at.procon.eventhub.processing.model.RuntimeActivityInterval;
import at.procon.eventhub.processing.model.RuntimeSupportEvent;
import at.procon.eventhub.processing.model.RuntimeVehicleUsageInterval;
import at.procon.eventhub.reference.CountryCodeNormalizer;
import java.util.List;
import java.util.UUID;
@ -89,10 +90,10 @@ public final class RuntimeDriverWorkingTimeAdapter {
supportEvent.occurredAt().toEpochSecond(),
supportEvent.latitude(),
supportEvent.longitude(),
supportEvent.country(),
CountryCodeNormalizer.normalizeSupportEvent(null, null, supportEvent.country()),
supportEvent.region(),
supportEvent.countryFrom(),
supportEvent.countryTo(),
CountryCodeNormalizer.normalizeSupportEvent(null, null, supportEvent.countryFrom()),
CountryCodeNormalizer.normalizeSupportEvent(null, null, supportEvent.countryTo()),
supportEvent.operation(),
supportEvent.odometerKm(),
supportEvent.avgSpeedKmh(),
@ -120,4 +121,4 @@ public final class RuntimeDriverWorkingTimeAdapter {
private static <T> List<T> safe(List<T> values) {
return values == null ? List.of() : values;
}
}
}

View File

@ -0,0 +1,210 @@
package at.procon.eventhub.reference;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
/**
* Converts country identifiers used by tachograph sources and reverse-geocoding
* providers to one canonical representation: ISO 3166-1 alpha-2.
*
* <p>Tachograph nation identifiers are not ISO alpha-2 identifiers. They may be
* numeric (for example {@code 1}, {@code 12}, {@code 13}) or use tachograph
* alphabetic values such as {@code A}, {@code D}, {@code UK}, and {@code SLO}.
* Those values must be resolved through {@link TachographNationRegistry} before
* they are compared with Nominatim values such as {@code AT}, {@code CZ}, and
* {@code DE}.</p>
*/
public final class CountryCodeNormalizer {
private static final Map<String, String> TACHOGRAPH_ALPHA_TO_ISO2 = buildTachographAlphaMap();
private CountryCodeNormalizer() {
}
/**
* Normalizes a tachograph numeric or alphabetic country identifier to ISO alpha-2.
*/
public static String normalizeTachograph(String value) {
String normalized = normalizeInput(value);
if (normalized == null) {
return null;
}
TachographNationRegistry.NationResolution resolution =
TachographNationRegistry.resolve(normalized, null);
if (resolution.known()) {
String mapped = TACHOGRAPH_ALPHA_TO_ISO2.get(normalizeInput(resolution.legacyNation()));
if (mapped != null) {
return mapped;
}
}
String mapped = TACHOGRAPH_ALPHA_TO_ISO2.get(normalized);
if (mapped != null) {
return mapped;
}
// Some sources already expose ISO values even though they are part of a
// tachograph pipeline. Preserve those values when no tachograph mapping exists.
return normalizeIso(normalized);
}
/**
* Normalizes an ISO alpha-2 or alpha-3 country identifier to ISO alpha-2.
*/
public static String normalizeIso(String value) {
String normalized = normalizeInput(value);
if (normalized == null) {
return null;
}
if (isAlphabeticAlpha2(normalized)) {
return normalized;
}
for (String iso2 : Locale.getISOCountries()) {
Locale locale = Locale.of("", iso2);
try {
if (locale.getISO3Country().equalsIgnoreCase(normalized)) {
return iso2;
}
} catch (MissingResourceException ignored) {
// Ignore incomplete locale entries and continue with the remaining countries.
}
}
return null;
}
/**
* Normalizes a support-event country value while respecting its source type.
* Numeric values are always tachograph nation codes. Explicit tachograph source
* metadata also selects tachograph semantics. Other providers are interpreted as
* ISO first to avoid ambiguities such as {@code FR}, which is an ISO code for
* France but a legacy tachograph code for the Faroe Islands.
*/
public static String normalizeSupportEvent(
String sourceFamily,
String sourceKind,
String value
) {
String normalized = normalizeInput(value);
if (normalized == null) {
return null;
}
if (isInteger(normalized) || isTachographSource(sourceFamily, sourceKind)) {
return normalizeTachograph(normalized);
}
String iso = normalizeIso(normalized);
if (iso != null) {
return iso;
}
return normalizeTachograph(normalized);
}
private static boolean isTachographSource(String sourceFamily, String sourceKind) {
String family = normalizeInput(sourceFamily);
String kind = normalizeInput(sourceKind);
return containsTachographMarker(family) || containsTachographMarker(kind);
}
private static boolean containsTachographMarker(String value) {
if (value == null) {
return false;
}
return value.contains("TACHOGRAPH")
|| value.equals("DRIVER_CARD")
|| value.equals("VEHICLE_UNIT")
|| value.equals("CARD")
|| value.equals("VU")
|| value.startsWith("CARD_")
|| value.startsWith("VU_");
}
private static boolean isInteger(String value) {
if (value == null || value.isEmpty()) {
return false;
}
for (int index = 0; index < value.length(); index++) {
if (!Character.isDigit(value.charAt(index))) {
return false;
}
}
return true;
}
private static boolean isAlphabeticAlpha2(String value) {
return value != null
&& value.length() == 2
&& Character.isLetter(value.charAt(0))
&& Character.isLetter(value.charAt(1));
}
private static String normalizeInput(String value) {
if (value == null) {
return null;
}
String normalized = value.trim().toUpperCase(Locale.ROOT);
return normalized.isEmpty() ? null : normalized;
}
private static Map<String, String> buildTachographAlphaMap() {
Map<String, String> values = new LinkedHashMap<>();
values.put("A", "AT");
values.put("AL", "AL");
values.put("AND", "AD");
values.put("ARM", "AM");
values.put("AZ", "AZ");
values.put("B", "BE");
values.put("BG", "BG");
values.put("BIH", "BA");
values.put("BY", "BY");
values.put("CH", "CH");
values.put("CY", "CY");
values.put("CZ", "CZ");
values.put("D", "DE");
values.put("DK", "DK");
values.put("E", "ES");
values.put("EST", "EE");
values.put("F", "FR");
values.put("FIN", "FI");
values.put("FL", "LI");
values.put("FR", "FO");
values.put("UK", "GB");
values.put("GE", "GE");
values.put("GR", "GR");
values.put("H", "HU");
values.put("HR", "HR");
values.put("I", "IT");
values.put("IRL", "IE");
values.put("IS", "IS");
values.put("KZ", "KZ");
values.put("L", "LU");
values.put("LT", "LT");
values.put("LV", "LV");
values.put("M", "MT");
values.put("MC", "MC");
values.put("MD", "MD");
values.put("MK", "MK");
values.put("N", "NO");
values.put("NL", "NL");
values.put("P", "PT");
values.put("PL", "PL");
values.put("RO", "RO");
values.put("RSM", "SM");
values.put("RUS", "RU");
values.put("S", "SE");
values.put("SK", "SK");
values.put("SLO", "SI");
values.put("TM", "TM");
values.put("TR", "TR");
values.put("UA", "UA");
values.put("V", "VA");
values.put("YU", "RS");
values.put("MNE", "ME");
values.put("SRB", "RS");
values.put("UZ", "UZ");
values.put("TJ", "TJ");
return Map.copyOf(values);
}
}

View File

@ -9,6 +9,7 @@ import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeRe
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeSupportGeoEvent;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVehicleUsageInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeVuCardAbsentInterval;
import at.procon.eventhub.reference.CountryCodeNormalizer;
import at.procon.eventhub.tachographfilesession.model.TachographEsperActivityIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDailyWeeklyRestCandidateCoverageIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperDrivingInterruptionIntervalEvent;
@ -730,10 +731,10 @@ public record TachographEsperDriverProcessingResultDto(
value.eventLifecycle(),
value.registrationKey(),
value.vehicleKey(),
value.country(),
CountryCodeNormalizer.normalizeTachograph(value.country()),
value.region(),
value.countryFrom(),
value.countryTo(),
CountryCodeNormalizer.normalizeTachograph(value.countryFrom()),
CountryCodeNormalizer.normalizeTachograph(value.countryTo()),
value.operation(),
value.latitude(),
value.longitude(),

View File

@ -22,6 +22,7 @@ import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialHo
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialInVehicleOvernightStayIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperPotentialInVehicleTripIntervalEvent;
import at.procon.eventhub.tachographfilesession.model.TachographEsperVuCardAbsentIntervalEvent;
import at.procon.eventhub.reference.CountryCodeNormalizer;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Comparator;
@ -129,8 +130,8 @@ public final class TachographDriverWorkingTimeAdapter {
}
return new RuntimeSupportEvidenceEvent(
supportEvent.eventId(),
null,
null,
"TACHOGRAPH_FILE_SESSION",
"TACHOGRAPH",
supportEvent.eventDomain(),
supportEvent.eventType(),
supportEvent.eventLifecycle(),
@ -141,10 +142,10 @@ public final class TachographDriverWorkingTimeAdapter {
supportEvent.occurredAt().toEpochSecond(),
supportEvent.latitude(),
supportEvent.longitude(),
supportEvent.country(),
CountryCodeNormalizer.normalizeTachograph(supportEvent.country()),
supportEvent.region(),
supportEvent.countryFrom(),
supportEvent.countryTo(),
CountryCodeNormalizer.normalizeTachograph(supportEvent.countryFrom()),
CountryCodeNormalizer.normalizeTachograph(supportEvent.countryTo()),
supportEvent.operation(),
supportEvent.odometerKm(),
supportEvent.avgSpeedKmh(),

View File

@ -10,6 +10,7 @@ import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.ResolvedActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ResolvedDriverTimeline;
import at.procon.eventhub.tachographfilesession.model.ResolvedVehicleUsageInterval;
import at.procon.eventhub.reference.CountryCodeNormalizer;
import java.util.List;
public final class RuntimeTimelineCompatibilityAdapter {
@ -163,10 +164,10 @@ public final class RuntimeTimelineCompatibilityAdapter {
supportEvent.slot(),
supportEvent.registrationKey(),
supportEvent.vehicleKey(),
supportEvent.country(),
CountryCodeNormalizer.normalizeTachograph(supportEvent.country()),
supportEvent.region(),
supportEvent.countryFrom(),
supportEvent.countryTo(),
CountryCodeNormalizer.normalizeTachograph(supportEvent.countryFrom()),
CountryCodeNormalizer.normalizeTachograph(supportEvent.countryTo()),
supportEvent.operation(),
supportEvent.latitude(),
supportEvent.longitude(),

View File

@ -50,6 +50,26 @@ eventhub:
concurrent-consumers: 4
block-when-full: true
queue-offer-timeout: 5m
reverse-geocoding:
enabled: ${NOMINATIM_ENABLED:true}
provider: NOMINATIM
nominatim:
# Public OSM Nominatim requires an identifying User-Agent and no more than one request/second.
# Configure a self-hosted endpoint here when higher throughput is required.
base-url: ${NOMINATIM_BASE_URL:http://nominatim.iothings.at:7070}
# Deliberate opt-in is required for the donated public service. Prefer a self-hosted or contracted endpoint for production/bulk processing.
public-service-enabled: ${NOMINATIM_PUBLIC_SERVICE_ENABLED:false}
user-agent: ${NOMINATIM_USER_AGENT:eventhub-tachograph/0.1 (Nominatim reverse geocoding)}
email: ${NOMINATIM_EMAIL:}
accept-language: ${NOMINATIM_ACCEPT_LANGUAGE:en}
connect-timeout: ${NOMINATIM_CONNECT_TIMEOUT:5s}
read-timeout: ${NOMINATIM_READ_TIMEOUT:30s}
minimum-request-interval: ${NOMINATIM_MIN_REQUEST_INTERVAL:0s}
cache-ttl: ${NOMINATIM_CACHE_TTL:30d}
cache-max-entries: ${NOMINATIM_CACHE_MAX_ENTRIES:100000}
coordinate-decimal-places: ${NOMINATIM_COORDINATE_DECIMAL_PLACES:3}
max-remote-lookups-per-execution: ${NOMINATIM_MAX_REMOTE_LOOKUPS_PER_EXECUTION:100000}
tachograph:
default-chunk-days: 1
occurred-at-overlap: 7d
@ -128,6 +148,15 @@ eventhub:
processing:
operating-split-idle-hours: 7
significant-driving-minutes: 3
ndi-long-minutes: 450
ndi-very-long-minutes: 1440
ndi-card-removal-percent: 80
ndi-visit-share-percent: 25
ndi-dbscan-eps-meters: 150
ndi-dbscan-min-points: 3
ndi-location-cache-ttl: 4h
ndi-location-cache-max-observations: 100000
ndi-location-cache-namespace: default
merge-gap-seconds: 0
gap-detection-tolerance-seconds: 0
timeline-input-mode: events

View File

@ -0,0 +1,213 @@
package at.procon.eventhub.geocoding.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.geocoding.model.GeoCountryResolution;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.InetSocketAddress;
import java.net.http.HttpClient;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class NominatimGeoCountryResolverTest {
private HttpServer server;
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
}
@Test
void resolvesCountryWithRequiredHeadersAndCachesCoordinate() throws Exception {
AtomicInteger requestCount = new AtomicInteger();
AtomicReference<String> userAgent = new AtomicReference<>();
AtomicReference<String> query = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/reverse", exchange -> respond(exchange, requestCount, userAgent, query));
server.start();
EventHubProperties properties = new EventHubProperties();
EventHubProperties.Nominatim config = properties.getReverseGeocoding().getNominatim();
config.setBaseUrl("http://localhost:" + server.getAddress().getPort());
config.setUserAgent("eventhub-test/1.0");
config.setMinimumRequestInterval(Duration.ZERO);
config.setCoordinateDecimalPlaces(4);
NominatimGeoCountryResolver resolver = new NominatimGeoCountryResolver(
properties,
new ObjectMapper(),
HttpClient.newHttpClient(),
Clock.systemUTC()
);
GeoCountryResolution first = resolver.resolve(
new BigDecimal("48.208200"),
new BigDecimal("16.373800"),
true
);
GeoCountryResolution second = resolver.resolve(
new BigDecimal("48.208200"),
new BigDecimal("16.373800"),
false
);
assertThat(first.resolved()).isTrue();
assertThat(first.countryCode()).isEqualTo("AT");
assertThat(first.remoteRequestPerformed()).isTrue();
assertThat(first.cacheHit()).isFalse();
assertThat(second.resolved()).isTrue();
assertThat(second.cacheHit()).isTrue();
assertThat(second.remoteRequestPerformed()).isFalse();
assertThat(requestCount).hasValue(1);
assertThat(userAgent).hasValue("eventhub-test/1.0");
assertThat(query.get())
.contains("format=jsonv2")
.contains("zoom=3")
.contains("layer=address")
.contains("lat=48.208200")
.contains("lon=16.373800");
}
@Test
void retriesWithoutContentNegotiationHeadersWhenEndpointReturns406() throws Exception {
AtomicInteger requestCount = new AtomicInteger();
AtomicReference<String> firstAccept = new AtomicReference<>();
AtomicReference<String> secondAccept = new AtomicReference<>();
AtomicReference<String> secondAcceptLanguage = new AtomicReference<>();
AtomicReference<String> secondUserAgent = new AtomicReference<>();
AtomicReference<String> secondQuery = new AtomicReference<>();
server = HttpServer.create(new InetSocketAddress(0), 0);
server.createContext("/reverse", exchange -> {
int currentRequest = requestCount.incrementAndGet();
if (currentRequest == 1) {
firstAccept.set(exchange.getRequestHeaders().getFirst("Accept"));
byte[] body = "Not acceptable".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(406, body.length);
exchange.getResponseBody().write(body);
exchange.close();
return;
}
secondAccept.set(exchange.getRequestHeaders().getFirst("Accept"));
secondAcceptLanguage.set(exchange.getRequestHeaders().getFirst("Accept-Language"));
secondUserAgent.set(exchange.getRequestHeaders().getFirst("User-Agent"));
secondQuery.set(exchange.getRequestURI().getRawQuery());
byte[] body = ("{\"place_id\":1,\"display_name\":\"Vienna, Austria\","
+ "\"address\":{\"country\":\"Austria\",\"country_code\":\"at\"}}")
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
EventHubProperties properties = new EventHubProperties();
EventHubProperties.Nominatim config = properties.getReverseGeocoding().getNominatim();
config.setBaseUrl("http://localhost:" + server.getAddress().getPort());
config.setUserAgent("eventhub-test/1.0");
config.setEmail("email@address");
config.setMinimumRequestInterval(Duration.ZERO);
NominatimGeoCountryResolver resolver = new NominatimGeoCountryResolver(
properties,
new ObjectMapper(),
HttpClient.newHttpClient(),
Clock.systemUTC()
);
GeoCountryResolution result = resolver.resolve(
new BigDecimal("48.2082"),
new BigDecimal("16.3738"),
true
);
assertThat(result.resolved()).isTrue();
assertThat(result.countryCode()).isEqualTo("AT");
assertThat(requestCount).hasValue(2);
assertThat(firstAccept).hasValue("application/json");
assertThat(secondAccept.get()).isNull();
assertThat(secondAcceptLanguage.get()).isNull();
assertThat(secondUserAgent).hasValue("");
assertThat(secondQuery.get())
.contains("format=jsonv2")
.contains("email=email%40address")
.contains("zoom=3")
.doesNotContain("layer=")
.doesNotContain("accept-language=")
.doesNotContain("addressdetails=");
}
@Test
void requiresExplicitOptInForPublicOsmEndpoint() {
EventHubProperties properties = new EventHubProperties();
NominatimGeoCountryResolver resolver = new NominatimGeoCountryResolver(
properties,
new ObjectMapper(),
HttpClient.newHttpClient(),
Clock.systemUTC()
);
GeoCountryResolution result = resolver.resolve(
new BigDecimal("48.2082"),
new BigDecimal("16.3738"),
true
);
assertThat(result.resolved()).isFalse();
assertThat(result.remoteRequestPerformed()).isFalse();
assertThat(result.errorMessage()).contains("explicit opt-in");
}
@Test
void doesNotCallRemoteServiceWhenDisabled() {
EventHubProperties properties = new EventHubProperties();
properties.getReverseGeocoding().setEnabled(false);
NominatimGeoCountryResolver resolver = new NominatimGeoCountryResolver(
properties,
new ObjectMapper(),
HttpClient.newHttpClient(),
Clock.systemUTC()
);
GeoCountryResolution result = resolver.resolve(
new BigDecimal("48.2082"),
new BigDecimal("16.3738"),
true
);
assertThat(result.resolved()).isFalse();
assertThat(result.remoteRequestPerformed()).isFalse();
}
private void respond(
HttpExchange exchange,
AtomicInteger requestCount,
AtomicReference<String> userAgent,
AtomicReference<String> query
) throws IOException {
requestCount.incrementAndGet();
userAgent.set(exchange.getRequestHeaders().getFirst("User-Agent"));
query.set(exchange.getRequestURI().getRawQuery());
byte[] body = ("{\"place_id\":1,\"display_name\":\"Vienna, Austria\","
+ "\"licence\":\"Data © OpenStreetMap contributors, ODbL 1.0\","
+ "\"address\":{\"country\":\"Austria\",\"country_code\":\"at\"}}")
.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
}
}

View File

@ -0,0 +1,63 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiLocationObservation;
import java.time.OffsetDateTime;
import java.util.List;
import org.junit.jupiter.api.Test;
class DriverNdiDbscanClustererTest {
private final DriverNdiDbscanClusterer clusterer = new DriverNdiDbscanClusterer();
@Test
void clustersThreeNearbyLocationsAndKeepsRemoteLocationAsNoise() {
List<DriverNdiLocationObservation> observations = List.of(
observation("A", "D1", 48.20820, 16.37380),
observation("B", "D2", 48.20835, 16.37390),
observation("C", "D3", 48.20810, 16.37405),
observation("D", "D4", 48.25000, 16.45000)
);
DriverNdiDbscanClusterer.ClusterResult result = clusterer.cluster(observations, 150.0d, 3);
assertThat(result.clusters()).hasSize(1);
assertThat(result.assignmentByObservationId().get("A"))
.isEqualTo(result.assignmentByObservationId().get("B"))
.isEqualTo(result.assignmentByObservationId().get("C"));
assertThat(result.assignmentByObservationId().get("D"))
.isEqualTo(DriverNdiDbscanClusterer.NOISE_CLUSTER_ID);
}
private DriverNdiLocationObservation observation(
String observationId,
String driverKey,
double latitude,
double longitude
) {
OffsetDateTime start = OffsetDateTime.parse("2026-05-01T00:00:00Z");
return new DriverNdiLocationObservation(
observationId,
"TENANT",
List.of(),
null,
driverKey,
start,
start.plusHours(8),
8 * 3600L,
latitude,
longitude,
"PREVIOUS_DRIVE_END",
null,
null,
null,
"P-" + observationId,
"N-" + observationId,
"REG-1",
"REG-1",
"VIN-1",
"VIN-1"
);
}
}

View File

@ -0,0 +1,301 @@
package at.procon.eventhub.processing.driverworkingtime.homeclassification.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationReason;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeStatus;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeRestCoverageInterval;
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class DriverNdiHomeClassificationServiceTest {
private DriverNdiLocationCorpusCache cache;
private DriverNdiHomeClassificationService service;
@BeforeEach
void setUp() {
EventHubProperties properties = new EventHubProperties();
cache = new DriverNdiLocationCorpusCache(properties);
service = new DriverNdiHomeClassificationService(
null,
cache,
new DriverNdiDbscanClusterer(),
properties
);
}
@Test
void accumulatesOtherDriverLocationsAcrossFileSessionsAndUsesThemAsCompanyHome() {
OffsetDateTime start = OffsetDateTime.parse("2026-05-01T00:00:00Z");
Map<String, List<DriverWorkingTimeRestCoverageInterval>> firstSession = new LinkedHashMap<>();
firstSession.put("D2", List.of(evidence("D2", start, 8, 48.20820, 16.37380, 0.0d, "VIN-DEPOT", "VIN-DEPOT")));
firstSession.put("D3", List.of(evidence("D3", start.plusDays(1), 8, 48.20825, 16.37385, 0.0d, "VIN-DEPOT", "VIN-DEPOT")));
firstSession.put("D4", List.of(evidence("D4", start.plusDays(2), 8, 48.20815, 16.37375, 0.0d, "VIN-DEPOT", "VIN-DEPOT")));
service.classifyEvidence(request(UUID.randomUUID()), firstSession);
Map<String, List<DriverWorkingTimeRestCoverageInterval>> secondSession = Map.of(
"D1",
List.of(evidence("D1", start.plusDays(3), 8, 48.20822, 16.37382, 0.0d, "VIN-DEPOT", "VIN-DEPOT"))
);
DriverNdiHomeClassificationScopeResult result = service.classifyEvidence(
request(UUID.randomUUID()),
secondSession
);
DriverNdiHomeClassificationResult driver = result.resultForDriver("D1");
assertThat(driver.cachedActualDriverObservationCount()).isEqualTo(1);
assertThat(driver.cachedOtherDriverObservationCount()).isEqualTo(3);
assertThat(driver.companyHomeClusterCount()).isEqualTo(1);
assertThat(driver.classifications()).singleElement().satisfies(classification -> {
assertThat(classification.status()).isEqualTo(DriverNdiHomeStatus.HOME);
assertThat(classification.reason()).isEqualTo(DriverNdiHomeClassificationReason.COMPANY_HOME_CLUSTER);
});
}
@Test
void identifiesDriverPrivateHomeSeparatelyFromOtherDriverLocations() {
OffsetDateTime start = OffsetDateTime.parse("2026-05-01T00:00:00Z");
Map<String, List<DriverWorkingTimeRestCoverageInterval>> evidenceByDriver = new LinkedHashMap<>();
evidenceByDriver.put("D1", List.of(
evidence("D1", start, 8, 48.20820, 16.37380, 0.0d, "VIN-A", "VIN-A"),
evidence("D1", start.plusDays(1), 8, 48.20825, 16.37385, 0.0d, "VIN-A", "VIN-A"),
evidence("D1", start.plusDays(2), 8, 48.20815, 16.37375, 0.0d, "VIN-A", "VIN-A"),
evidence("D1", start.plusDays(3), 8, 48.30000, 16.50000, 0.0d, "VIN-A", "VIN-A")
));
for (int index = 0; index < 9; index++) {
double latitude = 47.0d + index * 0.1d;
double longitude = 14.0d + index * 0.1d;
evidenceByDriver.put(
"OTHER-" + index,
List.of(evidence(
"OTHER-" + index,
start.plusDays(10L + index),
8,
latitude,
longitude,
0.0d,
"VIN-" + index,
"VIN-" + index
))
);
}
DriverNdiHomeClassificationResult driver = service.classifyEvidence(
request(UUID.randomUUID()),
evidenceByDriver
).resultForDriver("D1");
assertThat(driver.companyHomeClusterCount()).isZero();
assertThat(driver.driverHomeClusterCount()).isEqualTo(1);
assertThat(driver.classifications().subList(0, 3))
.allSatisfy(classification -> {
assertThat(classification.status()).isEqualTo(DriverNdiHomeStatus.HOME);
assertThat(classification.reason()).isEqualTo(DriverNdiHomeClassificationReason.DRIVER_HOME_CLUSTER);
});
assertThat(driver.classifications().get(3).reason())
.isEqualTo(DriverNdiHomeClassificationReason.LONG_REST_OUTSIDE_HOME_CLUSTER);
}
@Test
void reprocessingTheSameNdiUpdatesLocationWithoutDuplicatingTheCachedVisit() {
OffsetDateTime start = OffsetDateTime.parse("2026-05-01T00:00:00Z");
service.classifyEvidence(
request(UUID.randomUUID()),
Map.of("D1", List.of(evidence("D1", start, 8, 48.20820, 16.37380, 0.0d, "VIN-A", "VIN-A")))
);
DriverNdiHomeClassificationScopeResult result = service.classifyEvidence(
request(UUID.randomUUID()),
Map.of("D1", List.of(evidence("D1", start, 8, 48.20900, 16.37450, 0.0d, "VIN-A", "VIN-A")))
);
assertThat(result.cachedObservationCount()).isEqualTo(1);
assertThat(result.resultForDriver("D1").cachedActualDriverObservationCount()).isEqualTo(1);
assertThat(result.resultForDriver("D1").classifications())
.singleElement()
.satisfies(classification -> {
assertThat(classification.latitude()).isEqualTo(48.20900);
assertThat(classification.longitude()).isEqualTo(16.37450);
});
}
@Test
void doesNotTreatRegistrationChangeAsVehicleChangeWhenVinIsUnchanged() {
OffsetDateTime start = OffsetDateTime.parse("2026-05-01T00:00:00Z");
DriverWorkingTimeRestCoverageInterval interval = evidenceWithIdentity(
"D1",
start,
2,
null,
null,
0.0d,
"REG-A",
"REG-B",
"VIN-A",
"VIN-A"
);
DriverNdiHomeClassificationResult result = service.classifyEvidence(
request(UUID.randomUUID()),
Map.of("D1", List.of(interval))
).resultForDriver("D1");
assertThat(result.classifications()).singleElement().satisfies(classification -> {
assertThat(classification.status()).isEqualTo(DriverNdiHomeStatus.NOT_HOME);
assertThat(classification.reason()).isEqualTo(DriverNdiHomeClassificationReason.NO_POSITION_SHORT_REST);
});
}
@Test
void appliesRulesInDocumentOrderBeforeLocationClassification() {
OffsetDateTime start = OffsetDateTime.parse("2026-05-01T00:00:00Z");
List<DriverWorkingTimeRestCoverageInterval> evidence = List.of(
evidence("D1", start, 1, null, null, 0.0d, "VIN-A", "VIN-B"),
evidence("D1", start.plusDays(1), 2, null, null, 81.0d, "VIN-A", "VIN-A"),
evidence("D1", start.plusDays(2), 25, 48.5, 16.5, 0.0d, "VIN-A", "VIN-A"),
evidence("D1", start.plusDays(4), 8, null, null, 0.0d, "VIN-A", "VIN-A"),
evidence("D1", start.plusDays(5), 2, null, null, 0.0d, "VIN-A", "VIN-A")
);
DriverNdiHomeClassificationResult driver = service.classifyEvidence(
request(UUID.randomUUID()),
Map.of("D1", evidence)
).resultForDriver("D1");
assertThat(driver.classifications())
.extracting(value -> value.reason())
.containsExactly(
DriverNdiHomeClassificationReason.VEHICLE_CHANGED,
DriverNdiHomeClassificationReason.CARD_REMOVED_OVER_THRESHOLD,
DriverNdiHomeClassificationReason.REST_OVER_VERY_LONG_THRESHOLD,
DriverNdiHomeClassificationReason.NO_POSITION_LONG_REST,
DriverNdiHomeClassificationReason.NO_POSITION_SHORT_REST
);
assertThat(driver.classifications())
.extracting(value -> value.status())
.containsExactly(
DriverNdiHomeStatus.HOME,
DriverNdiHomeStatus.HOME,
DriverNdiHomeStatus.HOME,
DriverNdiHomeStatus.HOME,
DriverNdiHomeStatus.NOT_HOME
);
}
private UnifiedRuntimeProcessingApiRequest request(UUID sessionId) {
return new UnifiedRuntimeProcessingApiRequest(
sessionId,
List.of(sessionId),
null,
"TENANT",
Set.of(UnifiedEventSourceFamily.TACHOGRAPH_FILE_SESSION),
null,
Set.of("DRIVER_CARD", "VEHICLE_UNIT"),
null,
Set.of(),
true,
Set.of(),
true,
null,
null,
null,
null,
null,
true,
0,
true,
3,
720,
true,
true
);
}
private DriverWorkingTimeRestCoverageInterval evidence(
String driverKey,
OffsetDateTime start,
int durationHours,
Double latitude,
Double longitude,
double cardAbsentCoveragePercent,
String previousVehicle,
String nextVehicle
) {
return evidenceWithIdentity(
driverKey,
start,
durationHours,
latitude,
longitude,
cardAbsentCoveragePercent,
"REG-A",
previousVehicle.equals(nextVehicle) ? "REG-A" : "REG-B",
previousVehicle,
nextVehicle
);
}
private DriverWorkingTimeRestCoverageInterval evidenceWithIdentity(
String driverKey,
OffsetDateTime start,
int durationHours,
Double latitude,
Double longitude,
double cardAbsentCoveragePercent,
String previousRegistration,
String nextRegistration,
String previousVehicle,
String nextVehicle
) {
OffsetDateTime end = start.plusHours(durationHours);
long durationSeconds = durationHours * 3600L;
return new DriverWorkingTimeRestCoverageInterval(
UUID.randomUUID(),
driverKey,
start,
end,
durationSeconds,
Math.round(durationSeconds * cardAbsentCoveragePercent / 100.0d),
cardAbsentCoveragePercent,
"DRIVE-BEFORE-" + start,
"DRIVE-AFTER-" + start,
previousRegistration,
nextRegistration,
previousVehicle,
nextVehicle,
null,
null,
latitude == null ? null : "GEO-" + start,
latitude == null ? null : "POSITION",
latitude == null ? null : start,
latitude,
longitude,
latitude == null ? null : 0L,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
}
}

View File

@ -423,4 +423,103 @@ class DriverWorkingTimeReusableProjectionBuilderTest {
.isNull();
}
@Test
void buildsEnrichedCoverageForNdiBelowLegacyRestThreshold() {
DriverWorkingTimeReusableProjectionBuilder builder =
new DriverWorkingTimeReusableProjectionBuilder(new EventHubProperties());
UUID sessionId = UUID.randomUUID();
OffsetDateTime from = OffsetDateTime.parse("2026-05-01T08:00:00Z");
OffsetDateTime firstDriveEnd = OffsetDateTime.parse("2026-05-01T09:00:00Z");
OffsetDateTime secondDriveStart = OffsetDateTime.parse("2026-05-01T10:00:00Z");
OffsetDateTime to = OffsetDateTime.parse("2026-05-01T11:00:00Z");
DriverWorkingTimeProcessingInput input = new DriverWorkingTimeProcessingInput(
sessionId,
"12:123",
"DRIVER_CARD",
from,
to,
from,
to,
3,
720,
List.of(
new DriverWorkingTimeActivityInterval(
sessionId,
"12:123",
"ACT-1",
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
"ACT-1",
"ACT-1",
from,
firstDriveEnd,
from.toEpochSecond(),
firstDriveEnd.toEpochSecond(),
firstDriveEnd.toEpochSecond() - from.toEpochSecond(),
List.of("ACT-1"),
false,
false,
"RAW_INTERVAL"
),
new DriverWorkingTimeActivityInterval(
sessionId,
"12:123",
"ACT-2",
"DRIVE",
"DRIVER",
"INSERTED",
"SINGLE",
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
"ACT-2",
"ACT-2",
secondDriveStart,
to,
secondDriveStart.toEpochSecond(),
to.toEpochSecond(),
to.toEpochSecond() - secondDriveStart.toEpochSecond(),
List.of("ACT-2"),
false,
false,
"RAW_INTERVAL"
)
),
List.of(
new DriverWorkingTimeVehicleUsageInterval(
sessionId,
"12:123",
"VU-1",
"VU-START",
"VU-END",
from,
to,
from.toEpochSecond(),
to.toEpochSecond(),
to.toEpochSecond() - from.toEpochSecond(),
null,
null,
"12:REG-1",
"VIN-1",
"DRIVER_CARD",
List.of("VU-START", "VU-END")
)
),
List.of(),
List.of()
);
assertThat(builder.buildDerivedProjectionBundle(input).dailyWeeklyRestCandidateCoverageIntervals()).isEmpty();
assertThat(builder.buildAllNonDrivingIntervalCoverage(input))
.singleElement()
.satisfies(interval -> assertThat(interval.durationSeconds()).isEqualTo(3600L));
}
}

View File

@ -0,0 +1,484 @@
package at.procon.eventhub.processing.driverworkingtime.tripsegmentation.service;
import static org.assertj.core.api.Assertions.assertThat;
import at.procon.eventhub.config.EventHubProperties;
import at.procon.eventhub.geocoding.model.GeoCountryResolution;
import at.procon.eventhub.geocoding.model.GeoCountryResolutionStatus;
import at.procon.eventhub.geocoding.service.GeoCountryResolver;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassification;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationReason;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeStatus;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeRestCoverageInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDriverPartition;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeProcessingInput;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentBoundarySource;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationResult;
import at.procon.eventhub.processing.driverworkingtime.tripsegmentation.model.DriverCountryTripSegmentationScopeResult;
import at.procon.eventhub.processing.eventprocessing.support.RuntimeSupportEvidenceEvent;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class DriverCountryTripSegmentationServiceTest {
private static final String DRIVER = "13:DRIVER-1";
private static final String VEHICLE = "VIN-1";
private static final String REGISTRATION = "W-12345";
@Test
void prefersExplicitBorderCrossingAndExistingCountryCodes() {
AtomicInteger resolverCalls = new AtomicInteger();
GeoCountryResolver resolver = (latitude, longitude, allowRemoteLookup) -> {
resolverCalls.incrementAndGet();
return unresolved(latitude, longitude);
};
DriverCountryTripSegmentationService service = new DriverCountryTripSegmentationService(
resolver,
properties()
);
List<RuntimeSupportEvidenceEvent> supportEvents = List.of(
position("p-at", "2026-05-01T08:05:00Z", "48.2082", "16.3738", "A"),
border("b-at-de", "2026-05-01T10:00:00Z", "48.75", "13.84", "A", "D"),
position("p-de", "2026-05-01T11:00:00Z", "48.90", "13.40", "D")
);
DriverCountryTripSegmentationScopeResult scope = service.segmentPreparedInputs(
Map.of(DRIVER, preparedInput(supportEvents))
);
DriverCountryTripSegmentationResult result = scope.resultForDriver(DRIVER);
assertThat(resolverCalls).hasValue(0);
assertThat(result.segmentCount()).isEqualTo(2);
assertThat(result.explicitBorderCrossingCount()).isEqualTo(1);
assertThat(result.segments().get(0).countryFrom()).isEqualTo("AT");
assertThat(result.segments().get(0).countryTo()).isEqualTo("DE");
assertThat(result.segments().get(0).endBoundarySource())
.isEqualTo(DriverCountryTripSegmentBoundarySource.EXPLICIT_BORDER_CROSSING);
assertThat(result.segments().get(1).countryFrom()).isEqualTo("DE");
assertThat(result.segments().get(1).countryTo()).isEqualTo("DE");
}
@Test
void normalizesNumericTachographCountriesBeforeComparingWithIsoCountries() {
AtomicInteger resolverCalls = new AtomicInteger();
GeoCountryResolver resolver = (latitude, longitude, allowRemoteLookup) -> {
resolverCalls.incrementAndGet();
String code = longitude.compareTo(new BigDecimal("15")) > 0 ? "AT" : "DE";
return new GeoCountryResolution(
GeoCountryResolutionStatus.RESOLVED,
latitude,
longitude,
code,
null,
null,
"NOMINATIM",
null,
false,
true,
null
);
};
DriverCountryTripSegmentationService service = new DriverCountryTripSegmentationService(
resolver,
properties()
);
List<RuntimeSupportEvidenceEvent> supportEvents = List.of(
position("p-at", "2026-05-01T08:05:00Z", "48.2082", "16.3738", "1"),
border("b-at-de", "2026-05-01T10:00:00Z", "48.75", "13.84", "1", "13"),
position("p-de", "2026-05-01T11:00:00Z", "48.90", "13.40", null)
);
DriverCountryTripSegmentationResult result = service.segmentPreparedInputs(
Map.of(DRIVER, preparedInput(supportEvents))
).resultForDriver(DRIVER);
assertThat(resolverCalls).hasValue(1);
assertThat(result.segmentCount()).isEqualTo(2);
assertThat(result.segments().get(0).countryCode()).isEqualTo("AT");
assertThat(result.segments().get(0).countryFrom()).isEqualTo("AT");
assertThat(result.segments().get(0).countryTo()).isEqualTo("DE");
assertThat(result.segments().get(1).countryCode()).isEqualTo("DE");
assertThat(result.segments().get(1).countryFrom()).isEqualTo("DE");
assertThat(result.segments().get(1).countryTo()).isEqualTo("DE");
assertThat(result.segments())
.allSatisfy(segment -> {
assertThat(segment.countryCode()).doesNotMatch("\\d+");
assertThat(segment.countryFrom()).doesNotMatch("\\d+");
assertThat(segment.countryTo()).doesNotMatch("\\d+");
});
}
@Test
void usesNominatimWhenPositionCountryIsMissing() {
AtomicInteger resolverCalls = new AtomicInteger();
GeoCountryResolver resolver = (latitude, longitude, allowRemoteLookup) -> {
resolverCalls.incrementAndGet();
String code = longitude.compareTo(new BigDecimal("15")) > 0 ? "AT" : "DE";
return new GeoCountryResolution(
GeoCountryResolutionStatus.RESOLVED,
latitude,
longitude,
code,
null,
null,
"NOMINATIM",
"Data © OpenStreetMap contributors, ODbL 1.0",
false,
true,
null
);
};
DriverCountryTripSegmentationService service = new DriverCountryTripSegmentationService(
resolver,
properties()
);
List<RuntimeSupportEvidenceEvent> supportEvents = List.of(
position("p-at", "2026-05-01T08:05:00Z", "48.2082", "16.3738", null),
position("p-de", "2026-05-01T10:00:00Z", "48.90", "13.40", null)
);
DriverCountryTripSegmentationResult result = service.segmentPreparedInputs(
Map.of(DRIVER, preparedInput(supportEvents))
).resultForDriver(DRIVER);
assertThat(resolverCalls).hasValue(2);
assertThat(result.reverseGeocodingRemoteRequestCount()).isEqualTo(2);
assertThat(result.segmentCount()).isEqualTo(2);
assertThat(result.segments().get(0).countryFrom()).isEqualTo("AT");
assertThat(result.segments().get(0).countryTo()).isEqualTo("DE");
assertThat(result.segments().get(0).endBoundarySource())
.isEqualTo(DriverCountryTripSegmentBoundarySource.NOMINATIM_COUNTRY_CHANGE);
assertThat(result.segments().get(0).boundaryCountryReverseGeocoded()).isTrue();
}
@Test
void buildsCompleteTripBetweenHomeClassificationsAndAttachesContainedNonHomeIntervals() {
GeoCountryResolver resolver = (latitude, longitude, allowRemoteLookup) ->
unresolved(latitude, longitude);
DriverCountryTripSegmentationService service = new DriverCountryTripSegmentationService(
resolver,
properties()
);
List<RuntimeSupportEvidenceEvent> supportEvents = List.of(
position("p-at", "2026-05-01T08:05:00Z", "48.2082", "16.3738", "A"),
border("b-at-de", "2026-05-01T10:00:00Z", "48.75", "13.84", "A", "D"),
position("p-de", "2026-05-01T11:00:00Z", "48.90", "13.40", "D")
);
List<DriverNdiHomeClassification> classifications = List.of(
classification("leading", "2026-05-01T05:00:00Z", "2026-05-01T06:00:00Z", DriverNdiHomeStatus.NOT_HOME),
classification("home-start", "2026-05-01T06:00:00Z", "2026-05-01T08:00:00Z", DriverNdiHomeStatus.HOME),
classification("away-rest", "2026-05-01T12:00:00Z", "2026-05-01T13:00:00Z", DriverNdiHomeStatus.NOT_HOME),
classification("home-end", "2026-05-01T16:00:00Z", "2026-05-02T00:00:00Z", DriverNdiHomeStatus.HOME),
classification("trailing", "2026-05-02T01:00:00Z", "2026-05-02T02:00:00Z", DriverNdiHomeStatus.NOT_HOME)
);
DriverCountryTripSegmentationResult result = service.segmentPreparedInputs(
Map.of(DRIVER, preparedInput(supportEvents)),
homeScope(classifications)
).resultForDriver(DRIVER);
assertThat(result.tripCount()).isEqualTo(1);
assertThat(result.unassignedNonHomeClassificationCount()).isEqualTo(2);
assertThat(result.trips()).singleElement().satisfies(trip -> {
assertThat(trip.startedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T08:00:00Z"));
assertThat(trip.endedAt()).isEqualTo(OffsetDateTime.parse("2026-05-01T16:00:00Z"));
assertThat(trip.startHomeClassification().intervalId()).isEqualTo("home-start");
assertThat(trip.endHomeClassification().intervalId()).isEqualTo("home-end");
assertThat(trip.containedNonHomeClassifications())
.extracting(DriverNdiHomeClassification::intervalId)
.containsExactly("away-rest");
assertThat(trip.countrySegments()).hasSize(2);
assertThat(trip.countrySegments())
.allSatisfy(segment -> assertThat(segment.tripId()).isEqualTo(trip.tripId()));
assertThat(trip.countrySegments())
.extracting(segment -> segment.countryCode())
.containsExactly("AT", "DE");
});
}
@Test
void reusesMiddleHomeClassificationAsPreviousTripEndAndNextTripStart() {
GeoCountryResolver resolver = (latitude, longitude, allowRemoteLookup) ->
unresolved(latitude, longitude);
DriverCountryTripSegmentationService service = new DriverCountryTripSegmentationService(
resolver,
properties()
);
List<DriverNdiHomeClassification> classifications = List.of(
classification("home-1", "2026-05-01T06:00:00Z", "2026-05-01T08:00:00Z", DriverNdiHomeStatus.HOME),
classification("away-1", "2026-05-01T12:00:00Z", "2026-05-01T13:00:00Z", DriverNdiHomeStatus.NOT_HOME),
classification("home-2", "2026-05-01T16:00:00Z", "2026-05-01T18:00:00Z", DriverNdiHomeStatus.HOME),
classification("away-2", "2026-05-01T20:00:00Z", "2026-05-01T21:00:00Z", DriverNdiHomeStatus.NOT_HOME),
classification("home-3", "2026-05-02T02:00:00Z", "2026-05-02T08:00:00Z", DriverNdiHomeStatus.HOME)
);
DriverCountryTripSegmentationResult result = service.segmentPreparedInputs(
Map.of(DRIVER, preparedInput(List.of())),
homeScope(classifications)
).resultForDriver(DRIVER);
assertThat(result.trips()).hasSize(2);
assertThat(result.trips().get(0).endHomeClassification().intervalId()).isEqualTo("home-2");
assertThat(result.trips().get(1).startHomeClassification().intervalId()).isEqualTo("home-2");
assertThat(result.trips().get(0).containedNonHomeClassifications())
.extracting(DriverNdiHomeClassification::intervalId)
.containsExactly("away-1");
assertThat(result.trips().get(1).containedNonHomeClassifications())
.extracting(DriverNdiHomeClassification::intervalId)
.containsExactly("away-2");
}
private EventHubProperties properties() {
EventHubProperties properties = new EventHubProperties();
properties.getReverseGeocoding().getNominatim().setMaxRemoteLookupsPerExecution(10);
return properties;
}
private DriverNdiHomeClassificationScopeResult homeScope(
List<DriverNdiHomeClassification> classifications
) {
DriverNdiHomeClassificationResult result = new DriverNdiHomeClassificationResult(
DRIVER,
classifications.size(),
0,
0,
0,
0,
0,
classifications,
List.of(),
java.util.Set.of(),
java.util.Set.of(),
List.of()
);
return new DriverNdiHomeClassificationScopeResult(
"test-corpus",
0,
0,
0,
Map.of(DRIVER, result),
List.of()
);
}
private DriverNdiHomeClassification classification(
String intervalId,
String startedAt,
String endedAt,
DriverNdiHomeStatus status
) {
OffsetDateTime start = OffsetDateTime.parse(startedAt);
OffsetDateTime end = OffsetDateTime.parse(endedAt);
DriverWorkingTimeRestCoverageInterval evidence = new DriverWorkingTimeRestCoverageInterval(
UUID.randomUUID(),
DRIVER,
start,
end,
java.time.Duration.between(start, end).getSeconds(),
0,
0.0d,
"previous-" + intervalId,
"next-" + intervalId,
REGISTRATION,
REGISTRATION,
VEHICLE,
VEHICLE,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
);
return new DriverNdiHomeClassification(
intervalId,
evidence,
null,
null,
null,
null,
false,
status,
status == DriverNdiHomeStatus.HOME
? DriverNdiHomeClassificationReason.REST_OVER_VERY_LONG_THRESHOLD
: DriverNdiHomeClassificationReason.SHORT_REST
);
}
private DriverWorkingTimePreparedInput preparedInput(List<RuntimeSupportEvidenceEvent> supportEvents) {
UUID sessionId = UUID.randomUUID();
DriverWorkingTimeActivityInterval drive = new DriverWorkingTimeActivityInterval(
sessionId,
DRIVER,
"drive-1",
"DRIVE",
"1",
"INSERTED",
"SINGLE",
REGISTRATION,
VEHICLE,
"TACHOGRAPH_FILE_SESSION",
"drive-start",
"drive-end",
OffsetDateTime.parse("2026-05-01T08:00:00Z"),
OffsetDateTime.parse("2026-05-01T12:00:00Z"),
OffsetDateTime.parse("2026-05-01T08:00:00Z").toEpochSecond(),
OffsetDateTime.parse("2026-05-01T12:00:00Z").toEpochSecond(),
4 * 60 * 60,
List.of("drive-1"),
false,
false,
"RAW"
);
DriverWorkingTimeProcessingInput processingInput = new DriverWorkingTimeProcessingInput(
sessionId,
DRIVER,
"TACHOGRAPH_FILE_SESSION",
drive.startedAt(),
drive.endedAt(),
drive.startedAt(),
drive.endedAt(),
3,
720,
List.of(drive),
List.of(),
supportEvents,
List.of()
);
DriverWorkingTimeDriverPartition partition = new DriverWorkingTimeDriverPartition(
DRIVER,
List.of(),
List.of(),
List.of(),
List.of(),
List.of(),
null,
supportEvents,
null,
List.of(),
List.of()
);
return new DriverWorkingTimePreparedInput(DRIVER, partition, processingInput);
}
private RuntimeSupportEvidenceEvent position(
String eventId,
String occurredAt,
String latitude,
String longitude,
String countryCode
) {
return supportEvent(
eventId,
occurredAt,
"POSITION",
"POSITION_RECORDED",
latitude,
longitude,
countryCode,
null,
null
);
}
private RuntimeSupportEvidenceEvent border(
String eventId,
String occurredAt,
String latitude,
String longitude,
String countryFrom,
String countryTo
) {
return supportEvent(
eventId,
occurredAt,
"BORDER_CROSSING",
"BORDER_OUTBOUND",
latitude,
longitude,
null,
countryFrom,
countryTo
);
}
private RuntimeSupportEvidenceEvent supportEvent(
String eventId,
String occurredAt,
String eventDomain,
String eventType,
String latitude,
String longitude,
String countryCode,
String countryFrom,
String countryTo
) {
OffsetDateTime occurred = OffsetDateTime.parse(occurredAt);
return new RuntimeSupportEvidenceEvent(
eventId,
"TACHOGRAPH_FILE_SESSION",
"VEHICLE_UNIT",
eventDomain,
eventType,
"SNAPSHOT",
DRIVER,
VEHICLE,
REGISTRATION,
occurred,
occurred.toEpochSecond(),
new BigDecimal(latitude),
new BigDecimal(longitude),
countryCode,
null,
countryFrom,
countryTo,
null,
null,
null,
null,
Map.of()
);
}
private GeoCountryResolution unresolved(BigDecimal latitude, BigDecimal longitude) {
return new GeoCountryResolution(
GeoCountryResolutionStatus.NOT_FOUND,
latitude,
longitude,
null,
null,
null,
"NOMINATIM",
null,
false,
false,
"not found"
);
}
}

View File

@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import at.procon.eventhub.processing.driverworkingtime.dto.DriverWorkingTimeProcessingResultDto;
import at.procon.eventhub.processing.driverworkingtime.homeclassification.model.DriverNdiHomeClassificationScopeResult;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeActivityInterval;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimeDriverPartition;
import at.procon.eventhub.processing.driverworkingtime.model.DriverWorkingTimePreparedInput;
@ -202,6 +203,21 @@ class DriverWorkingTimeDerivedProjectionsModuleTest {
Map.of("12:123", preparedInput),
Map.of(),
List.of()
),
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION,
new RuntimeProcessingModuleResult(
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION,
RuntimeProcessingModuleStatus.SUCCESS,
new DriverNdiHomeClassificationScopeResult(
"TENANT|FILE_SESSION|NDI_HOME_V1",
0,
0,
0,
Map.of(),
List.of()
),
Map.of(),
List.of()
)
)
);

View File

@ -8,6 +8,8 @@ import at.procon.eventhub.processing.dto.UnifiedRuntimeDerivedProjectionResultDt
import at.procon.eventhub.processing.dto.UnifiedRuntimeProcessingApiRequest;
import at.procon.eventhub.processing.dto.UnifiedRuntimeDriverWorkingTimeScopeResultDto;
import at.procon.eventhub.processing.eventprocessing.dto.RuntimeEventPartitioningApiRequest;
import at.procon.eventhub.processing.eventprocessing.module.DriverWorkingTimeModuleKeys;
import at.procon.eventhub.processing.eventprocessing.module.RuntimeProcessingPipelineExecutor;
import at.procon.eventhub.processing.eventprocessing.partition.RuntimeEventPartitioningStrategy;
import at.procon.eventhub.processing.model.UnifiedEventSourceFamily;
import at.procon.eventhub.processing.model.UnifiedRuntimeProcessingRequest;
@ -77,6 +79,173 @@ class DriverWorkingTimeRuntimeProcessingPlanTest {
assertThat(resolved.vehicleExpansionPaddingMinutes()).isEqualTo(15);
}
@Test
void expandsSelectedFileSessionToAllDriversForNdiLearning() {
DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan(
Mockito.mock(RuntimeProcessingPipelineExecutor.class)
);
UnifiedRuntimeProcessingApiRequest expanded = plan.expandFileSessionLearningScope(sourceSelection(), true);
assertThat(expanded.driverKey()).isNull();
assertThat(expanded.driverKeys()).isEmpty();
assertThat(expanded.includeAllDrivers()).isTrue();
assertThat(expanded.sessionId()).isEqualTo(sourceSelection().sessionId());
}
@Test
void recognizesSessionOnlySelectionAsFileSessionLearningScope() {
DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan(
Mockito.mock(RuntimeProcessingPipelineExecutor.class)
);
UnifiedRuntimeProcessingApiRequest source = sourceSelection();
UnifiedRuntimeProcessingApiRequest sessionOnly = new UnifiedRuntimeProcessingApiRequest(
source.sessionId(),
source.sessionIds(),
source.compositeSessionId(),
source.tenantKey(),
Set.of(),
source.eventBackend(),
source.sourceKinds(),
source.driverKey(),
source.driverKeys(),
source.includeAllDrivers(),
source.vehicleKeys(),
source.includeAllVehicles(),
source.driverSourceEntityId(),
source.driverCardNation(),
source.driverCardNumber(),
source.occurredFrom(),
source.occurredTo(),
source.expandVehicleEvents(),
source.vehicleExpansionPaddingMinutes(),
source.includeIntersectingIntervals(),
source.significantDrivingMinutes(),
source.minimumRestPeriodMinutes(),
source.includeActivityIntervals(),
source.includeDrivingIntervals(),
source.sourceInputs()
);
UnifiedRuntimeProcessingApiRequest expanded = plan.expandFileSessionLearningScope(sessionOnly, true);
assertThat(expanded.includeAllDrivers()).isTrue();
assertThat(expanded.driverKey()).isNull();
assertThat(expanded.sessionId()).isEqualTo(sessionOnly.sessionId());
}
@Test
void doesNotExpandAlternateDriverSelectorWithoutCanonicalDriverKey() {
DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan(
Mockito.mock(RuntimeProcessingPipelineExecutor.class)
);
UnifiedRuntimeProcessingApiRequest selectedByCard = new UnifiedRuntimeProcessingApiRequest(
sourceSelection().sessionId(),
sourceSelection().sessionIds(),
sourceSelection().compositeSessionId(),
sourceSelection().tenantKey(),
sourceSelection().sourceFamilies(),
sourceSelection().eventBackend(),
sourceSelection().sourceKinds(),
null,
Set.of(),
false,
sourceSelection().vehicleKeys(),
sourceSelection().includeAllVehicles(),
"driver-source-1",
"A",
"CARD-1",
sourceSelection().occurredFrom(),
sourceSelection().occurredTo(),
sourceSelection().expandVehicleEvents(),
sourceSelection().vehicleExpansionPaddingMinutes(),
sourceSelection().includeIntersectingIntervals(),
sourceSelection().significantDrivingMinutes(),
sourceSelection().minimumRestPeriodMinutes(),
sourceSelection().includeActivityIntervals(),
sourceSelection().includeDrivingIntervals(),
sourceSelection().sourceInputs()
);
assertThat(plan.expandFileSessionLearningScope(selectedByCard, true)).isSameAs(selectedByCard);
}
@Test
void clearsAlternateSelectorsWhenCanonicalDriverKeyAllowsSafeResponseFiltering() {
DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan(
Mockito.mock(RuntimeProcessingPipelineExecutor.class)
);
UnifiedRuntimeProcessingApiRequest requested = new UnifiedRuntimeProcessingApiRequest(
sourceSelection().sessionId(),
sourceSelection().sessionIds(),
sourceSelection().compositeSessionId(),
sourceSelection().tenantKey(),
sourceSelection().sourceFamilies(),
sourceSelection().eventBackend(),
sourceSelection().sourceKinds(),
sourceSelection().driverKey(),
sourceSelection().driverKeys(),
false,
sourceSelection().vehicleKeys(),
sourceSelection().includeAllVehicles(),
"driver-source-1",
"A",
"CARD-1",
sourceSelection().occurredFrom(),
sourceSelection().occurredTo(),
sourceSelection().expandVehicleEvents(),
sourceSelection().vehicleExpansionPaddingMinutes(),
sourceSelection().includeIntersectingIntervals(),
sourceSelection().significantDrivingMinutes(),
sourceSelection().minimumRestPeriodMinutes(),
sourceSelection().includeActivityIntervals(),
sourceSelection().includeDrivingIntervals(),
sourceSelection().sourceInputs()
);
UnifiedRuntimeProcessingApiRequest expanded = plan.expandFileSessionLearningScope(requested, true);
assertThat(expanded.includeAllDrivers()).isTrue();
assertThat(expanded.driverSourceEntityId()).isNull();
assertThat(expanded.driverCardNation()).isNull();
assertThat(expanded.driverCardNumber()).isNull();
}
@Test
void canDisableAllDriverExpansionForNdiLearning() {
DriverWorkingTimeRuntimeProcessingPlan plan = new DriverWorkingTimeRuntimeProcessingPlan(
Mockito.mock(RuntimeProcessingPipelineExecutor.class)
);
UnifiedRuntimeProcessingApiRequest requested = sourceSelection();
assertThat(plan.expandFileSessionLearningScope(requested, false)).isSameAs(requested);
}
@Test
void dedicatedHomePlanForcesNdiBeforeFinalProjectionAndEnablesAllDriverLearning() {
DriverHomeClassificationRuntimeProcessingPlan plan = new DriverHomeClassificationRuntimeProcessingPlan(
Mockito.mock(DriverWorkingTimeRuntimeProcessingPlan.class)
);
RuntimeProcessingExecutionApiRequest request = new RuntimeProcessingExecutionApiRequest(
DriverHomeClassificationRuntimeProcessingPlan.PLAN_KEY,
sourceSelection(),
null,
List.of(DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS),
Map.of()
);
RuntimeProcessingExecutionApiRequest delegated = plan.prepareDelegatedRequest(request);
assertThat(delegated.processingPlanKey()).isEqualTo(DriverWorkingTimeRuntimeProcessingPlan.PLAN_KEY);
assertThat(delegated.modules()).endsWith(
DriverWorkingTimeModuleKeys.NDI_HOME_CLASSIFICATION,
DriverWorkingTimeModuleKeys.COUNTRY_TRIP_SEGMENTATION,
DriverWorkingTimeModuleKeys.DRIVING_DERIVED_PROJECTIONS
);
assertThat(delegated.parameters())
.containsEntry(DriverWorkingTimeRuntimeProcessingPlan.NDI_LEARN_ALL_FILE_SESSION_DRIVERS_PARAMETER, true);
}
@Test
void executeCanOmitExtendedPartitionPayloads() {
RuntimeDriverWorkingTimeScopeProcessingService scopeService = Mockito.mock(RuntimeDriverWorkingTimeScopeProcessingService.class);

View File

@ -75,6 +75,42 @@ class RuntimeSupportEvidenceNormalizerTest {
assertThat(normalized.payload().path("raw").path("supportEventType").asText()).isEqualTo("BORDER_INBOUND");
}
@Test
void resolvesNumericTachographCountriesToCanonicalIsoCodes() {
ObjectNode payload = (ObjectNode) raw("DRIVER-1", "VIN-1", "1:W-1");
ObjectNode raw = (ObjectNode) payload.path("raw");
raw.put("sourceKind", "DRIVER_CARD");
raw.put("country", "13");
raw.put("countryFrom", "1");
raw.put("countryTo", "12");
EventHubEventDto border = new EventHubEventDto(
UUID.randomUUID(),
"border-numeric-1",
null,
vehicleRef("VIN-1", "1:W-1"),
OffsetDateTime.parse("2026-05-01T22:00:00Z"),
null,
OffsetDateTime.parse("2026-05-01T22:00:00Z"),
EventDomain.BORDER_CROSSING,
EventType.BORDER_OUTBOUND,
EventLifecycle.OUTBOUND,
null,
new GeoPointDto(new BigDecimal("48.5"), new BigDecimal("16.5")),
null,
null,
payload,
false,
null
);
RuntimeSupportEvidenceEvent support = normalizer.toSupportEvidenceEvent("DRIVER-1", border);
assertThat(support.countryCode()).isEqualTo("DE");
assertThat(support.countryFrom()).isEqualTo("AT");
assertThat(support.countryTo()).isEqualTo("CZ");
}
@Test
void doesNotNormalizeActivityOrCardUsageEvents() {
EventHubEventDto cardUsage = new EventHubEventDto(

View File

@ -0,0 +1,33 @@
package at.procon.eventhub.reference;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class CountryCodeNormalizerTest {
@Test
void resolvesNumericTachographNationCodesToIsoAlpha2() {
assertThat(CountryCodeNormalizer.normalizeTachograph("1")).isEqualTo("AT");
assertThat(CountryCodeNormalizer.normalizeTachograph("12")).isEqualTo("CZ");
assertThat(CountryCodeNormalizer.normalizeTachograph("13")).isEqualTo("DE");
}
@Test
void resolvesAlphabeticTachographNationCodesToIsoAlpha2() {
assertThat(CountryCodeNormalizer.normalizeTachograph("A")).isEqualTo("AT");
assertThat(CountryCodeNormalizer.normalizeTachograph("CZ")).isEqualTo("CZ");
assertThat(CountryCodeNormalizer.normalizeTachograph("D")).isEqualTo("DE");
assertThat(CountryCodeNormalizer.normalizeTachograph("UK")).isEqualTo("GB");
}
@Test
void keepsIsoAndTachographSemanticsSourceAware() {
assertThat(CountryCodeNormalizer.normalizeIso("at")).isEqualTo("AT");
assertThat(CountryCodeNormalizer.normalizeIso("DEU")).isEqualTo("DE");
assertThat(CountryCodeNormalizer.normalizeSupportEvent("YELLOWFOX", "POSITION", "FR"))
.isEqualTo("FR");
assertThat(CountryCodeNormalizer.normalizeSupportEvent("TACHOGRAPH_FILE_SESSION", "DRIVER_CARD", "FR"))
.isEqualTo("FO");
}
}