384.000 Züge, eine SQLite
Mai 2026 · RailCast-Pipeline, Phase 1+2
Anfang März hatte ich eine simple Frage: Welcher der elf Bahnhöfe, die ich regelmäßig nutze, ist eigentlich am unzuverlässigsten? Eine schnelle Google-Suche liefert: "Es kommt drauf an." Eine ehrlichere Antwort gibt es nicht, weil die DB ihre eigenen Verspätungsstatistiken auf Monats-Ebene aggregiert und nur global publiziert. Pro-Bahnhof-Daten gibt's nicht.
Acht Wochen später habe ich sie selber. RailCast ist ein Python-Daemon, der minütlich die DB-IRIS-TTS-Schnittstelle abfragt, alle 10 Minuten Open-Meteo-Wetter dazupackt, und alles in eine einzelne SQLite-Datei schreibt. Stand heute: 384.059 Stops, 46.298 Wetterbeobachtungen, ungefähr 425 MB auf der Disk. Wachsend mit etwa 8.000 Stops pro Tag.
Das ist ein Datensatz, mit dem man arbeiten kann — und ich tue das gerade. Dieser Text beschreibt, wie die Pipeline aussieht, warum sie absichtlich klein gehalten ist, und was ich schon vor dem Training irgendeines Modells gelernt habe.
Was gesammelt wird
Die DB betreibt unter iris.noncd.db.de/iris-tts/timetable eine XML-Schnittstelle, die seit Jahren ohne Auth-Layer und ohne Rate-Limit erreichbar ist. Zwei Endpoints sind interessant:
/plan/{eva}/{YYMMDD}/{HH}— der geplante Fahrplan für Bahnhofevain der StundeHH/fchg/{eva}— alle aktuellen Änderungen (Verspätungen, Gleiswechsel, Ausfälle) für die nächsten ~6 Stunden
Mein Collector zieht alle 5 Minuten plan für die laufende und kommende Stunde, alle 60 Sekunden fchg. Aus den XML-Dokumenten extrahiere ich pro Stop: Zugnummer, Linie, Routen-Anfang/Ende, geplante und tatsächliche Ankunfts-/Abfahrtszeit, Plattform, Cancellation-Flags, Disruption-Messages.
Stationen aktuell:
NRW: Köln Hbf, Köln Messe/Deutz, Düsseldorf Hbf, Bonn Hbf, Wuppertal Hbf
Ruhrgebiet: Essen Hbf, Dortmund Hbf
Großstädte: Frankfurt(Main)Hbf, Berlin Hbf, Hamburg Hbf, München Hbf
Das ist eine Mischung aus persönlichem Interesse (Wuppertal — wo ich wohne) und systematischem Spread, damit ich Pendler-Strecken (NRW), Knoten-Bahnhöfe (Berlin, Frankfurt) und gut-bediente Haupthäfen (München, Hamburg) drin habe. Mit den 11 Stationen erfasse ich rund 8.000 Stops pro Tag — genug für statistisch sinnvolle Aggregationen, klein genug um auf einer t3.small-VPS mit 2 GB RAM mitzulaufen, ohne andere Services zu stören.
Warum SQLite
Erste Frage, die ich bekomme, wenn ich das Setup zeige: Warum nicht Postgres? Oder TimescaleDB? Oder Parquet auf S3?
Antwort: weil es nicht nötig ist. SQLite mit Write-Ahead-Logging schreibt minütlich ein paar Hundert Rows ohne Schluckauf. Concurrent Reads vom EDA-Notebook während der Collector läuft funktionieren transparent. Die Datei ist 425 MB groß, das ganze Backup-Modell ist cp railcast.db railcast-backup.db. Wenn ich die Daten woandershin migrieren will, ist das ein VACUUM INTO und ein aws s3 cp entfernt.
Postgres hätte mich zwei Konfigurationsdateien, einen weiteren systemd-Service, einen Backup-Cron und eine Connection-Pool-Diskussion gekostet — für ein Single-Writer-Workload, der bequem unter 100 Inserts pro Minute bleibt. Das ist die falsche Komplexität.
Die Faustregel, die ich für solche Projekte nutze: SQLite, bis du nachweisen kannst, dass dir SQLite weh tut. Bisher tut es nicht weh. Wenn ich irgendwann auf Phase-3-ML komme und parallele Modell-Trainings mit grossen IO-Spikes laufen, denke ich neu darüber nach. Heute nicht.
Der Collector
Ein einziger Python-File, knapp 600 Zeilen, läuft als systemd-Service auf der zoopa-VPS. Keine externen Abhängigkeiten ausser urllib, xml.etree, sqlite3 — alles in der Stdlib. Das war Absicht: ich wollte beim ersten Crash nicht eine Pip-Dependency-Diskussion führen müssen.
Das Ablaufmuster ist trivial:
while not stop_signal:
now = datetime.now(timezone.utc)
if now - last_plan_fetch > PLAN_INTERVAL:
for eva in STATIONS:
fetch_plan(eva, now.hour) # current hour
fetch_plan(eva, (now + 1h).hour) # next hour, lookahead
last_plan_fetch = now
if now - last_fchg_fetch > CHANGE_INTERVAL:
for eva in STATIONS:
fetch_fchg(eva) # all changes
last_fchg_fetch = now
if now - last_weather_fetch > WEATHER_INTERVAL:
fetch_weather_for_all_stations()
last_weather_fetch = now
sleep(15)
Der Datensatz wächst mit jedem fchg-Update — wenn ein Zug aus dem Plan eine neue tatsächliche Ankunftszeit bekommt, schreibe ich eine neue Row. train_id ist die IRIS-Stop-ID, also unique pro Zug-und-Bahnhof; mit einem (train_id, fchg_seq)-Index kann ich die Verspätungs-History eines einzelnen Stops über alle fchg-Updates rekonstruieren.
Health-Metrics (Fetch-Latenz, HTTP-Errors pro 5min, Rows/min) gehen in eine zweite Tabelle collector_log. Wenn der Daemon mal sterben sollte, sehe ich es daran, dass nichts mehr ankommt — aber in den letzten 8 Wochen ist das genau einmal passiert (Reboot der VPS). systemd hat ihn vor mir wieder hochgezogen.
Was ich schon weiß, ohne ein Modell trainiert zu haben
Phase 2 (EDA in einem Jupyter-Notebook) hat ein paar Dinge zu Tage gefördert, die ich vorher nicht wusste oder nur vermutete:
Wuppertal Hbf hat eine ungefähre Pünktlichkeitsquote von 78%. Definiert als "<6 Minuten Verspätung an der Abfahrt" über alle Zugtypen. Das ist deutlich schlechter als der DB-Konzern-Mittelwert (~92% lt. eigener Statistik) und deutlich besser als mein Bauchgefühl (60%, das ich mehrfach geäussert hatte).
Verspätungen kaskadieren entlang einzelner Linien stark. Wenn die RE1 in Köln Hbf 12 Minuten Verspätung hat, ist die Wahrscheinlichkeit, dass dieselbe RE1 zwei Stationen später in Wuppertal nicht aufholt, ungefähr 80%. Das ist intuitiv klar, ich habe es jetzt aber als Korrelations-Plot.
Wetter-Korrelationen sind schwächer als erwartet. Bei Niederschlag >5mm/h sehe ich einen Verspätungs-Anstieg von ~3% relativ zum Trockenen. Schnee gibt's bisher in meiner Datenmenge zu wenig, um aussagekräftig zu sein. Hauptverspätungstreiber sind Linien-interne Effekte, nicht Wetter-Events.
Donnerstag- und Freitagabend sind die schlechtesten Pünktlichkeits-Slots. Spitzenverkehr plus Wochenend-Anreise. Sonntagabend ist überraschend pünktlich.
Das sind alles Korrelationen, keine Modelle. Phase 3 (ein Verspätungs-Modell auf Stop-Ebene mit Linien-, Bahnhofs-, Wochentag- und Wetter-Features) ist die Aufgabe für den nächsten Monat.
Was ich gelernt habe
Daten selber zu sammeln verändert die Beziehung zum Datensatz. Wenn ich ein Kaggle-Datenset lade, frage ich nicht, woher die Edge-Cases kommen. Wenn ich die Quelldaten parse, kenne ich die Edge-Cases — Züge mit plan_arr aber ohne plan_dep (Endhaltestelle), Züge mit actual_dep ohne actual_arr (jemand hat den Zug verspätet rausgelassen, ohne die Ankunft zu vermerken), Züge ohne Routen-Information weil IRIS-TTS sie nicht ausliefert für bestimmte Verkehrsverträge.
Diese Eigenheiten muss ich nicht im Modell-Code antizipieren — ich habe sie schon im Schema modelliert, weil ich sie in den XML-Daten gesehen habe.
Ein einziger Python-File ist eine valide Architektur. Ich habe lange gedacht, dass "richtige Pipelines" mit Airflow oder Prefect orchestriert werden müssen. Das stimmt — wenn man parallele Workloads mit Abhängigkeiten und Retry-Strategien hat. Für einen Single-Writer-Daemon, der eine Schnittstelle abfragt, ist while not stop_signal: … die richtige Antwort. Add-on systemd, fertig.
Eine Million Stops ist näher als man denkt. Bei 8.000 Stops pro Tag bin ich Ende Juni bei 1 Million. Spätestens dann sollte ich Phase 3 fertig haben. Datasets wachsen schneller als Modelle reifen — das wird ein wiederkehrendes Pattern, vermute ich.
Das Repo ist privat, der Datensatz nicht öffentlich abrufbar. Wenn ich Phase 3 abschließe, wahrscheinlich beides als Anonymisierung+Sample-Export — die Roh-IRIS-Daten enthalten gelegentlich Disposition-Hinweise, die ich nicht ungefiltert publishen will.