Wie die Zahlen entstehen

Diese Seite beschreibt die Berechnungen hinter Dashboard, Streckendetails, Heatmap, Tagesvergleich und Datenvergleich. Eine kürzere Antwort zur Outlier-Erkennung steht weiterhin in der FAQ.

Durchschnitt = Summe aller verwertbaren Fahrtdauern / Anzahl verwertbarer Messpunkte
Verspätung % = ((Ist-Fahrtdauer - Referenz-Fahrtdauer) / Referenz-Fahrtdauer) * 100
Heatmap-Zelle % = ((Median Stunde/Wochentag - Strecken-Durchschnitt) / Strecken-Durchschnitt) * 100

Welche Messwerte zählen?

Berechnungen verwenden nur Messungen mit positiver Fahrtdauer, die nicht manuell ausgeschlossen sind und deren data_status leer oder valid ist. Messungen mit 0 Sekunden, nicht verfügbare Werte, Fehler und als outlier markierte Punkte werden aus den Statistiken herausgefiltert.

Fahrtdauern liegen intern in Sekunden vor. Für die Anzeige werden sie in Minuten umgerechnet und meist auf eine Nachkommastelle gerundet.

usableReadings = readings.filter(reading =>
  reading.duration_seconds != null
  and reading.duration_seconds > 0
  and reading.exclude_from_stats != true
  and (reading.data_status == null or reading.data_status == "valid")
)

durationMinutes = round((reading.duration_seconds / 60) * 10) / 10

Aktuelle Fahrtdauer und Durchschnitt

Die aktuelle Fahrtdauer einer Strecke ist die neueste verwertbare Messung dieser Strecke. Der Durchschnitt ist das arithmetische Mittel aller verwertbaren historischen Messungen der Strecke: Summe der Fahrtdauern geteilt durch Anzahl der Messpunkte.

Der Live-Status vergleicht aktuelle Fahrtdauer und Durchschnitt: bis einschließlich Durchschnitt ist grün, bis unter 150 Prozent des Durchschnitts orange, ab 150 Prozent rot. Wenn eine Strecke aktuell keine frische verwertbare Messung hat, wird sie nicht in der Live-Rangliste der stärksten Verzögerungen geführt.

currentDuration = newest(usableReadings ordered by timestamp desc).duration_seconds / 60
averageDuration = average(usableReadings.duration_seconds) / 60
averageDuration = round(averageDuration * 10) / 10

if currentDuration <= averageDuration:
  status = "fast"
else if currentDuration >= averageDuration * 1.5:
  status = "slow"
else:
  status = "moderate"

rankableForLiveDelay =
  averageDuration > 0
  and currentDuration != null
  and consecutiveErrorStreak == 0

Verspätung in Prozent

Die Live-Verspätung ist die relative Abweichung vom Durchschnitt: ((aktuelle Fahrtdauer - Durchschnitt) / Durchschnitt) * 100. Positive Werte bedeuten langsamer als üblich, negative Werte schneller als üblich.

Im Tagesvergleich wird pro Strecke die prozentuale Änderung von Zeitraum A zu Zeitraum B als ((B - A) / A) * 100 berechnet. Positive Werte bedeuten: Zeitraum B war langsamer als A. Die Netzwerk-Kennzahl oben auf der Seite nutzt dagegen Zeitraum B als Referenz: ((A - B) / B) * 100.

liveDelayPercent =
  ((currentDuration - averageDuration) / averageDuration) * 100

routePercentChange =
  ((averageDurationB - averageDurationA) / averageDurationA) * 100

networkPercentChange =
  ((averageDurationA - averageDurationB) / averageDurationB) * 100

Gesamte verlorene Zeit im Tagesvergleich

Für Strecken mit Daten in beiden Vergleichszeiträumen wird zuerst der bessere der beiden Durchschnittswerte als Basis genommen. Die verlorene Zeit eines Zeitraums ist dann (Durchschnitt dieses Zeitraums - Basis) * Anzahl der Messpunkte.

Dadurch wird nicht behauptet, dass jede Minute absolut vermeidbar war. Die Kennzahl misst, wie viel Mehrzeit relativ zum besseren Vergleichszustand im betrachteten Datensatz steckt.

totalDelayA = 0
totalDelayB = 0

for route in routesWithDataInAAndB:
  baseline = min(route.avgDurationA, route.avgDurationB)
  totalDelayA += (route.avgDurationA - baseline) * route.dataPointsA
  totalDelayB += (route.avgDurationB - baseline) * route.dataPointsB

affectedRoutesCount = count(routes where abs(route.percentChange) > 10)

Verspätungs-Heatmap

Die Heatmap ist eine 24-mal-7-Matrix: Stunde des Tages gegen Wochentag. Pro Zelle wird der Median der verwertbaren Fahrtdauern dieser Stunde und dieses Wochentags berechnet. Montag steht links beziehungsweise oben in der Anzeige, Sonntag am Ende.

Die Farbe zeigt die relative Abweichung dieser Zelle vom Strecken-Durchschnitt: ((Median der Zelle - Basisdauer) / Basisdauer) * 100. Grün ist schneller als der Durchschnitt, Gelb nahe am Durchschnitt, Orange bis Rot langsamer. Leere Zellen bedeuten: keine verwertbaren Daten für diese Stunde/Wochentag-Kombination.

baselineDuration = average(usableReadings.duration_seconds) / 60
matrix = 24 rows x 7 columns filled with null

for hour in 0..23:
  for postgresWeekday in 0..6:
    cellReadings = usableReadings.filter(reading =>
      hour(reading.timestamp) == hour
      and dow(reading.timestamp) == postgresWeekday
    )

    if cellReadings.length > 0:
      medianMinutes = median(cellReadings.duration_seconds) / 60
      displayWeekday = postgresWeekday == 0 ? 6 : postgresWeekday - 1
      matrix[hour][displayWeekday] =
        round((((medianMinutes - baselineDuration) / baselineDuration) * 100) * 10) / 10

Peak-Traffic aus der Heatmap

Für die kurze Peak-Angabe werden nur Heatmap-Zellen betrachtet, die mehr als 10 Prozent über dem Durchschnitt liegen. Die Verzögerungswerte werden je Wochentag und je Stunde aufsummiert.

Angezeigt werden die bis zu drei Wochentage und Stunden mit der größten kumulierten Verzögerung. Das ist eine robuste Zusammenfassung der Heatmap, keine einzelne zufällige Spitzenmessung.

peakCells = []

for each matrix[hour][weekday] as delay:
  if delay != null and delay > 10:
    peakCells.push({ hour, weekday, delay })

weekdayScore[weekday] = sum(delay for peakCells with same weekday)
hourScore[hour] = sum(delay for peakCells with same hour)

topWeekdays = top 3 weekdayScore keys sorted by score desc
topHours = top 3 hourScore keys sorted by score desc

Lower Outlier: unrealistisch schnelle Messungen

Lower Outlier sind Messungen, die auffällig schnell sind und deshalb wahrscheinlich Mess- oder Matchingfehler darstellen. Pro Strecke wird eine gleitende Rückschau von 15 vorherigen Messungen verwendet.

Aus diesem Fenster wird das 25. Perzentil berechnet, also ein typischer Wert der schnellen Fahrten. Eine neue Messung wird als Lower Outlier markiert, wenn sie weniger als 35 Prozent dieses P25-Werts beträgt und zugleich mindestens 120 Sekunden schneller ist. Beides muss gelten, damit kurze Strecken nicht zu aggressiv gefiltert werden.

windowSize = 15

for each route:
  ordered = usableReadingsForRoute ordered by timestamp asc

  for i from windowSize to ordered.length - 1:
    current = ordered[i]
    window = ordered.slice(i - windowSize, i)
    p25 = percentile(sort(window.duration_seconds), 0.25)

    isPercentileOutlier = current.duration_seconds < p25 * 0.35
    isSignificantDiff = (p25 - current.duration_seconds) > 120

    if isPercentileOutlier and isSignificantDiff:
      mark current as data_status = "outlier"

Upper Outlier: unrealistisch langsame Messungen

Upper Outlier sind auffällig langsame Messungen. Die Erkennung arbeitet streckenweise und nutzt nur Strecken mit mindestens 50 verwertbaren Messungen. Grundlage sind Median, P95 und P99 der historischen Fahrtdauern.

Ein isolierter Spike wird markiert, wenn die Messung größer als Median * 5, größer als P99 * 2, mindestens 600 Sekunden über dem Median und mehr als dreimal so hoch wie die direkte vorherige und nachfolgende Messung ist.

Zusätzlich gibt es eine Plateau- beziehungsweise Nachtprüfung für längere oder off-peak Artefakte: größer als Median * 10, mindestens 600 Sekunden über dem Median und zugleich größer als P95 * 3 oder P99 * 1,5. Dazu muss Kontext passen: Nachbarwert über Median * 5 oder nachts zwischen 01:00 und 05:00 Uhr in Berlin zusätzlich größer als Median * 15.

for each route with count(usableReadings) >= 50:
  median = percentile(routeReadings.duration_seconds, 0.50)
  p95 = percentile(routeReadings.duration_seconds, 0.95)
  p99 = percentile(routeReadings.duration_seconds, 0.99)

  for each reading with previous and next reading:
    spike =
      reading.duration_seconds > median * 5
      and reading.duration_seconds > p99 * 2
      and reading.duration_seconds > previous.duration_seconds * 3
      and reading.duration_seconds > next.duration_seconds * 3
      and reading.duration_seconds > median + 600

    plateau =
      reading.duration_seconds > median * 10
      and reading.duration_seconds > median + 600
      and (reading.duration_seconds > p95 * 3 or reading.duration_seconds > p99 * 1.5)
      and (
        previous.duration_seconds > median * 5
        or next.duration_seconds > median * 5
        or (hourBerlin(reading.timestamp) between 1 and 5 and reading.duration_seconds > median * 15)
      )

    if spike or plateau:
      mark reading as data_status = "outlier"

Boxplot und Perzentile

Boxplots sortieren alle positiven verwertbaren Minutenwerte einer Strecke. Q1, Median, Q3, P90 und P95 werden per linear interpoliertem Perzentil berechnet: Liegt die gewünschte Position zwischen zwei Messpunkten, wird zwischen beiden Werten anteilig interpoliert.

Minimum und Maximum sind die kleinste und größte verwertbare Fahrtdauer nach Filterung. Die Zahl n ist die Anzahl der Werte, die in den Boxplot eingeflossen sind.

minutes = usableReadings.map(reading => reading.duration_seconds / 60)
sorted = sort(minutes ascending)

function percentile(sorted, p):
  if sorted.length == 1:
    return sorted[0]

  index = p * (sorted.length - 1)
  lower = floor(index)
  upper = ceil(index)

  if lower == upper:
    return sorted[lower]

  return sorted[lower] + (index - lower) * (sorted[upper] - sorted[lower])

boxplot = {
  min: sorted[0],
  q1: percentile(sorted, 0.25),
  median: percentile(sorted, 0.50),
  q3: percentile(sorted, 0.75),
  p90: percentile(sorted, 0.90),
  p95: percentile(sorted, 0.95),
  max: sorted[sorted.length - 1],
  n: sorted.length
}

Stau- und Monatskennzahlen

Für Allzeit-Stauwerte wird häufig die schnellste verwertbare Fahrt als Basis genutzt. Ein Tages-, Wochen- oder Monatswert ist dann ((Durchschnitt des Zeitraums - schnellste Fahrt) / schnellste Fahrt) * 100.

Die 15-Minuten-Reichweite verwendet die Streckenlänge und die Durchschnittsdauer: (Distanz in km / Durchschnittsdauer in Minuten) * 15. Für die Rushhour-Variante wird statt des Gesamtdurchschnitts der Durchschnitt aus 07:00-08:59 und 15:00-17:59 Uhr genutzt.

fastestTrip = min(usableReadings.duration_seconds) / 60

for each dayOrWeekOrMonth:
  periodAverage = average(periodReadings.duration_seconds) / 60
  congestionPercent = ((periodAverage - fastestTrip) / fastestTrip) * 100

distanceKm = route.distance_meters / 1000
distance15Min = (distanceKm / averageDurationMinutes) * 15

rushHourReadings = usableReadings.filter(reading =>
  hour(reading.timestamp) in 07:00-08:59 or 15:00-17:59
)
rushHourAverage = average(rushHourReadings.duration_seconds) / 60
distance15MinRushHour = (distanceKm / rushHourAverage) * 15

Datenvergleich: Abweichungen und Ausreißer

Im Datenvergleich werden Messpunkte zweier Datenquellen paarweise verglichen. Die absolute Abweichung ist |Quelle A - Quelle B|. Die relative Abweichung teilt diese Differenz durch den Mittelwert beider Quellen.

Vergleichs-Ausreißer werden markiert, wenn ihre absolute Abweichung größer als Mittelwert plus drei Standardabweichungen ist oder wenn die relative Abweichung über 20 Prozent liegt. Das betrifft die Analyse im Datenvergleich und ist getrennt von den dauerhaft markierten traffic_readings-Outliern.

pairs = matchedReadingsFromSourceAAndB
diffs = pairs.map(pair => abs(pair.durationA - pair.durationB))
meanDiff = average(diffs)
stdDiff = sqrt(average(diffs.map(diff => (diff - meanDiff) ^ 2)))
threshold = meanDiff + 3 * stdDiff

for each pair:
  deviation = abs(pair.durationA - pair.durationB)
  pairAverage = (pair.durationA + pair.durationB) / 2
  pctDeviation = pairAverage > 0 ? (deviation / pairAverage) * 100 : 0

  if deviation > threshold or pctDeviation > 20:
    mark pair as comparisonOutlier
Berechnungen - Verkehrsmonitoring Bonn