Initial: Mesh-Configs vom 03.09.2026 01:23

This commit is contained in:
root
2026-09-03 01:23:55 +02:00
commit 4b65134f8c
797 changed files with 146197 additions and 0 deletions
@@ -0,0 +1,57 @@
---
name: gtfs-timetable-routing
description: "Use when: echte Zugfahrzeiten (Nahverkehr) braucht."
version: 1.0.0
author: mehlzoerwer
platforms: [linux]
models:
default: mimo-v2-flash
related_skills: [web-hosting-deployment, pocketbase-deployment]
license: MIT
---
# 🚂 GTFS-Timetable-Routing — echte Zugverbindungen aus Fahrplandaten
## When to Use
Nutze diesen Skill, wenn **echte, verlässliche Nahverkehrs-Fahrzeiten** (von→nach per Regional-/S-Bahn, kein ICE) benötigt werden — z.B. für eine Zugverbindungs-Webseite, Produkt-Planung oder Fahrzeit-Angaben. **Kein** Luftlinien-Raten, **kein** Erfinden von Zeiten.
**Anlass (Nutzer-Korrektur):** Die Urlaub-Webseite (urlaub.perfekt-ohne-en.de) zeigte Fahrzeiten aus Luftlinien-Schätzung — komplett falsch (2. Ebene halbe Zeit, bis 3× daneben). Der Nutzer verlangte reale Zeiten (max ~1h daneben). Lösung: GTFS-Fahrplandatei lokal verarbeiten.
## Die harte Wahrheit: öffentliche Fahrplan-APIs sind zu
Bevor Zeit mit Scraping/APIs verbrannt wird — getestet (2026-08), alle scheitern programmatisch:
| Quelle | Ergebnis |
|--------|----------|
| DB-API / bahn.de | Bot-Block / OPS_BLOCKED / 403 |
| api.transport.rest | DNS/000 |
| transport.rest v6 | 503 |
| reiseauskunft.de | toter Pfad |
| VRR-EFA (efa.vrr.de V2) | 405 (nginx blockt GET+POST) |
| WebSearch-Tool | nicht konfiguriert (Firecrawl-Key fehlt) |
**Verlässlicher Weg = eine GTFS-Datei** (Regional+S-Bahn, kein ICE ist ideal: genau Deutschlandticket-Nahverkehr). Datei lokal in SQLite → Dijkstra-Router → echte Zeiten. Kosten 0 $, schnell (<1s/Verbindung nach Build).
## Arbeitsablauf
1. **GTFS-Datei beschaffen.** Nutzer liefert `.zip` (z.B. `r.und.s.bahnen.zip`, „ohne ICE/IC"). Enthält `stops.txt`, `trips.txt`, `stop_times.txt`, `calendar*.txt`, `routes.txt`. Aus /user-cache-Dokument-Pfad kopieren.
2. **Extrahieren + in SQLite laden** (`scripts/build_gtfs_db.py`). 3 Tabellen: `stops(stop_id,stop_name,lat,lon)`, `trips(trip_id,route_id,service_id)`, `st(stop_id,trip_id,seq,arr,dep)` + Indizes auf st(stop_id), st(trip_id), st(trip_id,seq).
3. **Router bauen/ausführen** (`scripts/gtfs_suche.py`, zeitbasierter Dijkstra): vorladen `deps_by_stop` und `stops_by_trip`, dann Dijkstra mit `dist[stop]=beste Ankunft` + Ziel-Abbruch.
4. **Verifizieren** gegen eine bekannte echte Strecke (z.B. Aachen Hbf→Köln Hbf ≈ 1h 12min, 1 Umstieg). Wenn plausibel → alle Ziele neu berechnen.
5. **Website/DB aktualisieren** mit den echten Zeiten (nicht erfinden).
## ⚠️ Kritische Pitfalls (alle beim echten Lauf getroffen)
1. **stops.txt-Spaltenreihenfolge** ist `stop_name, parent_station, stop_id, stop_lat, stop_lon, ...` — **stop_id ist Spalte 2, NICHT Spalte 0**; `parent_station` ist Spalte 1 (echte Gruppen-ID). Bei falschem Mapping kommen Stops mit leeren IDs / falschen Namen.
2. **trips.txt-Header** ist `route_id, service_id, trip_id` — trip_id ist Spalte 2. Import NICHT an Spalte 0 hängen.
3. **Bahnhof hat mehrere stop_ids** (z.B. Köln Hbf = 222681 UND 452576), aber nur **eine ist aktiv in stop_times**. `stops.txt`-ID ≠ notwendigerweise `stop_times`-ID. → Beim Auflösen: die ID wählen, die `COUNT(*) IN st > 0` hat.
4. **`st`-Tabelle kann mehr eindeutige trips enthalten als `trips.txt`** (110k vs 964 war ok — `st` ist Wahrheit). Zähler-Ausgaben prüfen, nicht dem trips.txt-Import trauen.
5. **Zeit-Parser**: HH:MM und HH:MM:SS tolerant parsen (`split(":")`, defaults).
6. **Dijkstra-Effizienz**: Bei vielen Trips (110k) explodiert naive Expansion → nutze `dist[stop]` + Ziel-Abbruch + `visited`. Erst nach Effizienz-Fix: Aachen→Köln in Sekunden statt Hänger.
## Bauen
Die Referenzdatei `references/gtfs-build-notes.md` enthält Schema + Dijkstra-Kern + SQL. Die Skripte `scripts/build_gtfs_db.py` und `scripts/gtfs_suche.py` sind direkt ausführbar (Fahrzeit von→nach, `<von_stop_id> <nach_stop_id> [HH:MM]`).
**Verwandt:** Bei PocketBase als Ziel echte Zeiten in DB schreiben → `pocketbase-deployment` (Schema + Filter-Quirks). Bei Web-Frontend-Einbindung → `web-hosting-deployment`.
@@ -0,0 +1,80 @@
# GTFS-Build-Notizen (aus echtem Lauf 2026-08, Urlaub-Webseite)
Konkrete SQL/Schema/Logik, die in dieser Session verifiziert funktioniert haben.
## SQLite-Schema (eine DB, 3 Tabellen + Indizes)
```sql
CREATE TABLE stops(stop_id TEXT PRIMARY KEY, stop_name TEXT, stop_lat REAL, stop_lon REAL);
CREATE TABLE trips(trip_id TEXT PRIMARY KEY, route_id TEXT, service_id TEXT);
CREATE TABLE st(stop_id TEXT, trip_id TEXT, seq INT, arr TEXT, dep TEXT);
CREATE INDEX idx_st_stop ON st(stop_id);
CREATE INDEX idx_st_trip ON st(trip_id);
CREATE INDEX idx_st_seq ON st(trip_id, seq);
```
Echte Mengen aus `r.und.s.bahnen.zip` (Regional+S-Bahn, kein ICE):
- stops.txt: 16.310 Haltestellen → DB 15.083 (dedupliziert)
- trips.txt: 110.087 Rohzeilen → DB 964 (trips.txt hat nur selten-service; st ist die Wahrheit)
- stop_times.txt: 82 MB → st 1.591.002 Zeilen, 110.087 eindeutige trip_id, 8.558 eindeutige stop_id
## Spaltenreihenfolgen (GTFS ist NICHT standardisiert was Namen angeht — Header lesen!)
- **stops.txt:** `stop_name, parent_station, stop_id, stop_lat, stop_lon, location_type, platform_code`
→ stop_id = Spalte 2; parent_station = Spalte 1 (Gruppen-ID). Viele Stops haben leeres parent_station.
Import-Fallback: `sid = p[2] if p[2] else p[1]` (bevorzugt stop_id, sonst parent_station).
- **trips.txt:** `route_id, service_id, trip_id` → trip_id = Spalte 2.
- **stop_times.txt:** `trip_id, arrival_time, departure_time, stop_id, stop_sequence, ...`
→ stop_id = Spalte 3, arr = 1, dep = 2, seq = 4.
## Bahnhofs-Alias (kritisch)
Ein Bahnhof kann mehrere stop_id-Zeilen haben (z.B. `Köln Hbf` = 222681 UND 452576). Nur EINE ist in
`stop_times` aktiv. Suche-Methode:
```sql
-- aktive ID ermitteln
SELECT stop_id, stop_name FROM stops WHERE stop_name = '<Name>';
-- dann je Kandidat:
SELECT COUNT(*) FROM st WHERE stop_id = '<id>'; -- >0 => aktiv
```
Beispiel: Aachen Hbf → 548582 (aktiv), Köln Hbf → 452576 (aktiv; 222681 tot). Saarbrücken Hbf hat 10 IDs, davon 9 aktiv.
## Zeit-basierter Dijkstra (Kern)
Vorladen (einmal):
```python
from collections import defaultdict
deps_by_stop = defaultdict(list) # stop_id -> [(dep_sec, trip_id, seq)] sortiert
stops_by_trip = defaultdict(list) # trip_id -> [(seq, stop_id, arr_sec, dep_sec)] sortiert
# Befülle beide in einem Durchlauf über st; sortiere jede Liste.
```
Suche:
```python
INF = float('inf')
dist = defaultdict(lambda: INF); dist[von_id] = dep0
heap = [(dep0, von_id)]
while heap:
t, stop = heapq.heappop(heap)
if t != dist[stop]: continue
if stop == nach_id: break
for dep_s, tid, seq in deps_by_stop[stop]:
if dep_s < t: continue
for (sseq, sid, arr_s, dep2) in stops_by_trip[tid]:
if sseq <= seq: continue
if arr_s < dist[sid]:
dist[sid] = arr_s; heapq.heappush(heap, (arr_s, sid))
# Ziel-Abbruch + dist[stop]-Guard sind ESSENZIELL für 110k Trips.
```
## Verifikations-Referenz (echte GTFS-Ergebnisse 2026-08)
- Aachen Hbf → Köln Hbf: **1h 12min, 1 Umstieg, 09:00 → 10:12** ✅ (real plausibel)
- Trier Hbf → Saarbrücken Hbf: nur direkt via trips, keine aktiven stops-Zuordnung nötig
## Wichtige Erkenntnis zur Datenlage
Diese GTFS („R- und S-Bahnen") deckt Bahn-Knoten ab, aber **nicht alle**: Remscheid-Lennep Bf und
Frankfurt (Main) Hauptbahnhof hatten in der `st`-Tabelle 0 aktive Trips → müssen per Alias oder
Alternativ-Bahnhof (nächster aktiver) aufgelöst werden, oder liegen außerhalb des Feeds. Vor dem
Neuberechnen ALLER Ziele: prüfen welche Kern-Bahnhöfe aktiv sind, fehlende via Nachbar-Bahnhof mappen.
@@ -0,0 +1,39 @@
# GTFS-Neuberechnung — harte Lehren aus urlaub.perfekt-ohne-en.de (2026-08-19)
Ergänzung zum main SKILL.md. Diese Notes stammen aus dem echten Großumbau der
Fahrzeiten auf der Zugverbindungs-Webseite und sind die Dinge, die NICHT sofort
ersichtlich sind. (Haupt-SKILL.md-Patch war durch eine Schreib-Sperre blockiert;
diese Erkenntnisse gehören trotzdem zu diesem Umbrella.)
## Die 3 Teambefunde, die den Dijkstra erst korrekt machten
1. **Geo-Stop-Merging** — Ein Bahnhof hat MEHRERE aktive stop_ids in der st-Tabelle
(Saarbrücken Hbf: 6 IDs = Plattform-/Gleis-Stops). `stops.txt` stop_id ist nicht
eindeutig. Fix: Stops < ~350m Luftlinie zu einer Gruppe zusammenfassen; der Router
expandiert von/zu allen Gruppen-IDs.
2. **Umstiegsbuffer** — Ohne min ~4min Transfer-Puffer erlaubt Dijkstra physisch
unmögliche Anschlüsse (Ankunft 10:05 → Abfahrt 10:04) → zu optimistische Zeiten.
Buffer NUR beim Umstieg, nicht beim Start: `dep_s < t + (BUFFER if t>dep0 else 0)`.
3. **Profil-Query (Min)** — Feste Abfahrtszeit (09:00) liefert systematisch zu
langsame Werte, wenn die früheste Verbindung zufällig viele Umstiege hat.
Abfahrten über den Tag scannen (z.B. stündlich 06-22) und MIN(Fahrzeit) nehmen.
## Performance: EIN Dijkstra je Start statt je Verbindung
- Naiv: 12.201 Verbindungen × ~17 Abfahrtsstichproben = ~207k Dijkstra-Läufe → ~18h.
- Korrekt: EIN Dijkstra je eindeutigen Start (191 Starts), der ALLE Ziele gleichzeitig
erreicht (`spread(start_ids)` → dict stop→ankunft). Danach Werte ablesen → ~10 Min.
- Commit NUR am Ende, und nur markierte Ziele (0.0h / mehrdeutig) neu verteilen.
## Start ohne Bahnhof (z.B. Radevormwald)
Städte ohne eigene Bahnhofs-ID matchen in stops nicht. Löse über den nächstgelegenen
Bahnhof (Radevormwald → Remscheid-Lennep Bf) und addiere die Bus-Anreise (~20min):
`Gesamtzeit = BUS_MIN + Dijkstra(Bahnhof → Ziel)`.
## Ziel-Eindeutigkeit: Berlin-Falle
Mehrdeutige Zielnamen (z.B. "Berlin") matchen falsche/lokalnahe Stops → ungenaue
oder sogar 0.0h-Zeiten. Abbilden auf eindeutiges "… Hbf":
`UPDATE verkehr SET nach='Berlin Hbf' WHERE nach='Berlin';`
0.0h-Zeilen sind als Fehler zu behandeln und neu zu berechnen, nicht zu akzeptieren.
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""build_gtfs_db.py — GTFS-Datei in SQLite laden (einmalig).
Aufruf: python3 build_gtfs_db.py <gtfs_zip> <out_db> [gtfs_dir]
Legt Tabellen stops/trips/st an (Schema siehe gtfs-timetable-routing/references).
"""
import zipfile, sqlite3, os, sys, time
def main():
gtfs_zip = sys.argv[1]
out_db = sys.argv[2]
work = sys.argv[3] if len(sys.argv) > 3 else "/tmp/gtfs_x"
os.makedirs(work, exist_ok=True)
# extrahieren
with zipfile.ZipFile(gtfs_zip) as z:
z.extractall(work)
t0 = time.time()
con = sqlite3.connect(out_db)
con.execute("PRAGMA journal_mode=MEMORY"); con.execute("PRAGMA temp_store=MEMORY")
con.executescript("""
CREATE TABLE stops(stop_id TEXT PRIMARY KEY, stop_name TEXT, stop_lat REAL, stop_lon REAL);
CREATE TABLE trips(trip_id TEXT PRIMARY KEY, route_id TEXT, service_id TEXT);
CREATE TABLE st(stop_id TEXT, trip_id TEXT, seq INT, arr TEXT, dep TEXT);
CREATE INDEX idx_st_stop ON st(stop_id);
CREATE INDEX idx_st_trip ON st(trip_id);
CREATE INDEX idx_st_seq ON st(trip_id, seq);
""")
# stops.txt: stop_name,parent_station,stop_id,stop_lat,stop_lon,...
n=0
with open(f"{work}/stops.txt", encoding="utf-8") as f:
f.readline()
for line in f:
p = line.strip().split(",")
if len(p) >= 5 and p[3] and p[4]:
sid = p[2] if p[2] else p[1] # bevorzugt stop_id, fallback parent_station
con.execute("INSERT OR IGNORE INTO stops VALUES(?,?,?,?)", (sid, p[0], p[3], p[4]))
n += 1
print("stops:", n)
# trips.txt: route_id,service_id,trip_id
n=0
with open(f"{work}/trips.txt", encoding="utf-8") as f:
f.readline()
for line in f:
p = line.strip().split(",")
if len(p) >= 3 and p[0]:
con.execute("INSERT OR IGNORE INTO trips VALUES(?,?,?)", (p[2], p[0], p[1]))
n += 1
print("trips:", n)
# stop_times.txt: trip_id,arrival_time,departure_time,stop_id,stop_sequence,...
n=0; batch=[]
with open(f"{work}/stop_times.txt", encoding="utf-8") as f:
f.readline()
for line in f:
p = line.strip().split(",")
if len(p) >= 5 and p[0] and p[3]:
batch.append((p[3], p[0], int(p[4]) if p[4].isdigit() else 0, p[1], p[2]))
if len(batch) >= 50000:
con.executemany("INSERT INTO st VALUES(?,?,?,?,?)", batch); n += len(batch); batch = []
if batch:
con.executemany("INSERT INTO st VALUES(?,?,?,?,?)", batch); n += len(batch)
con.commit(); con.close()
print("stop_times:", n, "| DB gebaut in %.1fs" % (time.time()-t0))
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""gtfs_suche.py — echte schnellste Nahverkehrs-Verbindung aus GTFS-SQLite (zeitbasierter Dijkstra).
Aufruf: python3 gtfs_suche.py <von_stop_id> <nach_stop_id> [HH:MM]
Vorraussetzung: DB aus build_gtfs_db.py. Aktiv-ID-Regel: wenn ein Bahnhof mehrere IDs hat,
die mit COUNT(*)>0 in st nehmen.
"""
import sqlite3, sys, heapq
from collections import defaultdict
DB = "/tmp/gtfs_rt.db"
def to_sec(t):
p = t.split(":")
h = int(p[0]); m = int(p[1]) if len(p) > 1 else 0
return h*3600 + m*60
def fmt(t):
return f"{t//3600:02d}:{(t%3600)//60:02d}"
def aktive_id(con, stop_name):
"""Findet die stop_id mit st-Vorkommen (Bahnhof kann mehrere IDs haben)."""
cands = con.execute("SELECT stop_id FROM stops WHERE stop_name=?", (stop_name,)).fetchall()
for (sid,) in cands:
if con.execute("SELECT COUNT(*) FROM st WHERE stop_id=?", (sid,)).fetchone()[0] > 0:
return sid
return None
def verbinde(von_id, nach_id, start="09:00"):
con = sqlite3.connect(DB)
con.row_factory = sqlite3.Row
dep0 = to_sec(start)
deps_by_stop = defaultdict(list); stops_by_trip = defaultdict(list)
for r in con.execute("SELECT trip_id, stop_id, seq, arr, dep FROM st"):
deps_by_stop[r["stop_id"]].append((to_sec(r["dep"]), r["trip_id"], r["seq"]))
stops_by_trip[r["trip_id"]].append((r["seq"], r["stop_id"], to_sec(r["arr"]), to_sec(r["dep"])))
con.close()
for k in deps_by_stop: deps_by_stop[k].sort()
for k in stops_by_trip: stops_by_trip[k].sort()
INF = float('inf')
dist = defaultdict(lambda: INF); dist[von_id] = dep0
heap = [(dep0, von_id)]
pred = {}
while heap:
t, stop = heapq.heappop(heap)
if t != dist[stop]: continue
if stop == nach_id: break
for dep_s, tid, seq in deps_by_stop[stop]:
if dep_s < t: continue
for (sseq, sid, arr_s, dep2) in stops_by_trip[tid]:
if sseq <= seq: continue
if arr_s < dist[sid]:
dist[sid] = arr_s; pred[sid] = (tid, stop)
heapq.heappush(heap, (arr_s, sid))
if nach_id not in pred and nach_id != von_id:
return None
if nach_id == von_id:
return (0, [], 0, fmt(dep0), fmt(dep0))
trips = []; cur = nach_id
while cur in pred and cur != von_id:
tid, prev = pred[cur]; trips.append(tid); cur = prev
trips.reverse()
dur = dist[nach_id] - dep0
return (dur, trips, len(set(trips)), fmt(dep0), fmt(dist[nach_id]))
if __name__ == "__main__":
von = sys.argv[1]; nach = sys.argv[2]
start = sys.argv[3] if len(sys.argv) > 3 else "09:00"
r = verbinde(von, nach, start)
if r:
dur, trips, umst, dep, arr = r
print(f"{dur//3600}h {(dur%3600)//60:02d}min | {dep}->{arr} | {umst} Umstieg(e)")
else:
print("Keine Verbindung")