eventhub/docs/ndi_home_classification.md

187 lines
7.5 KiB
Markdown

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