Compare commits
5 Commits
34e6c6f236
...
9ca8c88f91
| Author | SHA1 | Date |
|---|---|---|
|
|
9ca8c88f91 | |
|
|
4caadf1270 | |
|
|
7584bb8578 | |
|
|
c91d3cc1c4 | |
|
|
5a10558612 |
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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 driver’s 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:00–10:15, Austria → Germany
|
||||
Segment 2: 10:15–14:00, Germany → France
|
||||
Segment 3: 14:00–18: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 DI’s 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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package at.procon.eventhub.geocoding.model;
|
||||
|
||||
public enum GeoCountryResolutionStatus {
|
||||
RESOLVED,
|
||||
NOT_FOUND,
|
||||
DISABLED,
|
||||
REMOTE_LOOKUP_NOT_ALLOWED,
|
||||
ERROR
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package at.procon.eventhub.processing.driverworkingtime.homeclassification.model;
|
||||
|
||||
public enum DriverNdiHomeStatus {
|
||||
HOME,
|
||||
NOT_HOME
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue