eventhub/docs/ndi_home_classification.md

7.5 KiB

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)