WIP: Deutschland als zweite Datenquelle (gtfs.de)

- mehrere Quellen (ch, de) mit je eigenem Fahrplan und Echtzeit-Feed
- OSM CH+DE, mit osmium auf Schienennetz gefiltert, für pfaedle
- Landesgrenze CH gegen doppelte grenzüberschreitende Züge
- Import robust gegen andere Spaltenreihenfolge (gtfs.de)
- Kacheln für CH+DE (Zoom 14)

Noch nicht vollständig getestet (pfaedle auf Deutschland läuft).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-21 21:27:54 +02:00
parent 2e3c10a6c4
commit 2a4962c379
11 changed files with 370 additions and 168 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ FROM golang:1.25-trixie AS pmtiles
RUN go install github.com/protomaps/go-pmtiles@v1.31.2
FROM python:3.13-slim-trixie
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 \
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 osmium-tool \
&& rm -rf /var/lib/apt/lists/*
COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/
COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle
+30 -18
View File
@@ -1,23 +1,29 @@
# TrainPulse – Schweiz
# TrainPulse
Live-Karte aller Züge in der Schweiz: Position entlang der echten Strecke, Linie, Zugnummer,
Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte
(OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
Live-Karte aller Züge in der Schweiz und in Deutschland: Position entlang der echten Strecke,
Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
## Wie es funktioniert
```
opentransportdata.swiss ──GTFS (Mo+Do)──▶ updater-Container (stündlich)
├─ Filter: nur Bahn (route_type 100–199)
OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe
└─▶ data/feeds/<version>/
opentransportdata.swiss ──GTFS-RT (35 s)──▶ web-Container (Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
opentransportdata.swiss ──GTFS (Mo+Do)──┐
gtfs.de (DELFI) ─────────GTFS (täglich)──┼▶ updater-Container (stündlich)
│ ├─ Filter: nur Bahn (route_type 2, 100–199)
OpenStreetMap CH + DE (Geofabrik) ───────┴▶ ├─ osmium: nur Schienennetz, pfaedle: Streckenverläufe
└─▶ data/feeds/<ch|de>/<version>/
opentransportdata.swiss ──GTFS-RT (35 s)──┐
gtfs.de ─────────────────GTFS-RT (60 s)───┴▶ web-Container (Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
```
Es gibt in der Schweiz **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan
und gemeldeter Verspätung berechnet und auf den Streckenverlauf gelegt.
Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter
Verspätung berechnet und auf den Streckenverlauf gelegt.
Beide Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt der
Schweizer Fahrplan nur Züge innerhalb der Schweiz und der deutsche nur Züge ausserhalb
(Landesgrenze aus OpenStreetMap, `trainpulse/ch_border.json`).
Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr,
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
@@ -57,7 +63,7 @@ hinter deinen bestehenden Reverse Proxy hängen.
| Status | `curl localhost:8080/api/status` |
| Logs Webserver | `docker compose logs -f web` |
| Logs Fahrplan-Update | `docker compose logs -f updater` |
| Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --force` |
| Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --source de --force` (oder `ch`) |
| Selbsttest | `docker compose exec web python tests/test_core.py` |
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
@@ -65,7 +71,9 @@ hinter deinen bestehenden Reverse Proxy hängen.
Die Hinweise erscheinen unten rechts auf der Karte.
- Fahrplan & Echtzeit: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
- Fahrplan & Echtzeit Schweiz: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
- Fahrplan Deutschland: [gtfs.de](https://gtfs.de), Daten von DELFI e.V. (CC BY 4.0)
- Echtzeit Deutschland: [gtfs.de](https://gtfs.de/de/realtime/) (CC BY-SA 4.0, Beta, Abdeckung je nach Region)
- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com)
- Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL)
- Satellit Schweiz: © swisstopo (frei, Quellenangabe)
@@ -73,9 +81,13 @@ Die Hinweise erscheinen unten rechts auf der Karte.
## Bekannte Grenzen
- Nur Bahn. Busse, Trams und Schiffe sind im Fahrplan, werden aber noch herausgefiltert.
- Der Schweizer Fahrplan enthält auch einige ausländische Züge (z. B. TGV, TER); deren Strecke
ausserhalb der Schweiz ist nur Luftlinie, weil der OSM-Ausschnitt nur die Schweiz abdeckt.
- Nur Bahn. Busse, Trams und U-Bahnen sind im Fahrplan, werden aber noch herausgefiltert.
- Züge des Schweizer Fahrplans im übrigen Ausland (z. B. Tirano, Domodossola, Frankreich)
werden nicht angezeigt, sobald sie die Schweiz verlassen.
- Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt
wird als Ziel der letzte Halt.
- Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das
rund 9 GB Datenverkehr pro Tag.
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
für ein paar Sekunden minimal ab.
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
+32 -5
View File
@@ -1,5 +1,7 @@
"""Selbsttest mit einem Mini-Fahrplan: python -m pytest tests (oder: python tests/test_core.py)"""
import csv
import datetime as dt
import io
import json
import sys
import tempfile
@@ -36,14 +38,29 @@ SHAPE = "shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence,shape_dist_travele
DAY = dt.date(2026, 10, 21)
def make_feed(tmp: Path, shapes=True) -> Feed:
def german_style(files: dict) -> dict:
"""Wie gtfs.de: Spalten in anderer Reihenfolge, kein Fahrtziel/keine Zugnummer, Version 'latest-free'."""
out = {}
for n, c in files.items():
rows = list(csv.reader(io.StringIO(c)))
if n == "trips.txt":
rows = [r[:3] for r in rows] # route_id, service_id, trip_id
if n == "feed_info.txt":
rows[1][1] = "latest-free"
rows = [r[::-1] for r in rows] # Spalten rückwärts
out[n] = "".join(",".join(r) + "\n" for r in rows)
return out
def make_feed(tmp: Path, shapes=True, files=FILES) -> Feed:
tmp.mkdir(parents=True, exist_ok=True)
zp = tmp / "in.zip"
with zipfile.ZipFile(zp, "w") as z:
for n, c in FILES.items():
z.writestr(n, "" + c) # wie die echten Schweizer Dateien: mit BOM
meta = prepare.filter_rail(zp, tmp / "rail")
assert meta["feed_version"] == "20261017" and meta["trips"] == 1 and meta["stop_times"] == 3, meta
for n, c in files.items():
z.writestr(n, "\ufeff" + c) # wie die echten Schweizer Dateien: mit BOM
meta = prepare.filter_rail(zp, tmp / "rail", "20261020")
assert meta["feed_version"] == ("20261017" if files is FILES else "20261020"), meta
assert meta["trips"] == 1 and meta["stop_times"] == 3, meta
if shapes:
(tmp / "rail" / "shapes.txt").write_text(SHAPE)
prepare.pack_shapes(tmp / "rail")
@@ -127,6 +144,16 @@ def test_all():
g = make_feed(Path(d) / "noshape", shapes=False) # ohne shapes: Luftlinie
assert g.vehicles(at(8, 5), {})[0]["geometry"]["coordinates"] == [8.05, 47.0]
de = make_feed(Path(d) / "de", shapes=False, files=german_style(FILES)) # Format von gtfs.de
v = de.vehicles(at(8, 5), {})
assert len(v) == 1 and v[0]["properties"]["to"] == "Gamma" and v[0]["properties"]["nr"] == ""
assert de.vehicles(dt.datetime(2026, 10, 25, 8, 5, tzinfo=TZ).timestamp(), {}) == [] # calendar_dates
from trainpulse.border import SHOW, in_switzerland
assert in_switzerland(8.540, 47.378) and not in_switzerland(9.177, 47.659) # Zürich HB / Konstanz
assert not in_switzerland(8.690, 47.697) # Büsingen (deutsche Exklave)
assert SHOW["ch"](8.54, 47.378) and not SHOW["de"](8.54, 47.378)
print("alle Tests ok")
+27
View File
@@ -0,0 +1,27 @@
"""Landesgrenze der Schweiz (aus OpenStreetMap, auf ~400 m vereinfacht, mit den Exklaven Büsingen
und Campione als Löcher). Damit zeigt jeder Fahrplan seine Züge nur im eigenen Land, sonst wären
grenzüberschreitende Züge doppelt auf der Karte."""
import json
from pathlib import Path
_B = json.loads((Path(__file__).with_name("ch_border.json")).read_text())
_BBOX = (min(x for x, _ in _B["outer"]), min(y for _, y in _B["outer"]),
max(x for x, _ in _B["outer"]), max(y for _, y in _B["outer"]))
def _in_ring(x: float, y: float, ring) -> bool:
inside = False
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
inside = not inside
return inside
def in_switzerland(lon: float, lat: float) -> bool:
if not (_BBOX[0] <= lon <= _BBOX[2] and _BBOX[1] <= lat <= _BBOX[3]):
return False
return _in_ring(lon, lat, _B["outer"]) and not any(_in_ring(lon, lat, h) for h in _B["holes"])
# Welche Züge ein Fahrplan zeigen darf, wenn mehrere Quellen aktiv sind
SHOW = {"ch": in_switzerland, "de": lambda lon, lat: not in_switzerland(lon, lat)}
File diff suppressed because one or more lines are too long
+27 -8
View File
@@ -2,17 +2,36 @@ import os
from pathlib import Path
DATA_DIR = Path(os.environ.get("TRAINPULSE_DATA", "/var/lib/trainpulse"))
FEEDS_DIR = DATA_DIR / "feeds" # fertig aufbereitete Fahrpläne, ein Ordner pro feed_version
FEEDS_DIR = DATA_DIR / "feeds" # fertig aufbereitete Fahrpläne: feeds/<quelle>/<version>/
TZ = "Europe/Zurich"
# Schweiz: offizieller GTFS-Fahrplan + GTFS-RT (Trip Updates) von opentransportdata.swiss
GTFS_URL = os.environ.get("TRAINPULSE_GTFS_URL", "") # leer = automatisch das aktuelle Fahrplanjahr
RT_URL = os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt")
RT_KEY = os.environ.get("TRAINPULSE_RT_KEY", "")
RT_INTERVAL = 35 # Sekunden; Limit sind 2 Abfragen/Minute (Sliding Window)
# Datenquellen. Jede hat einen GTFS-Fahrplan und einen passenden GTFS-RT-Feed (Trip Updates).
SOURCES = {
"ch": { # opentransportdata.swiss; GTFS-URL leer = automatisch das aktuelle Fahrplanjahr
"gtfs_url": os.environ.get("TRAINPULSE_GTFS_URL", ""),
"rt_url": os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt"),
"rt_key": os.environ.get("TRAINPULSE_RT_KEY", ""),
"needs_key": True,
"rt_interval": 35, # Limit sind 2 Abfragen/Minute (Sliding Window)
},
"de": { # gtfs.de (Daten von DELFI e.V.), Fahrplan CC BY 4.0, Echtzeit CC BY-SA 4.0
"gtfs_url": os.environ.get("TRAINPULSE_DE_GTFS_URL", "https://download.gtfs.de/germany/free/latest.zip"),
"rt_url": os.environ.get("TRAINPULSE_DE_RT_URL", "https://realtime.gtfs.de/realtime-free.pb"),
"rt_key": "",
"needs_key": False,
"rt_interval": 60, # ~6 MB pro Abruf, öfter lohnt sich nicht
},
}
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de").split(",") if s in SOURCES]
OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf")
# OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
"https://download.geofabrik.de/europe/switzerland-latest.osm.pbf",
"https://download.geofabrik.de/europe/germany-latest.osm.pbf",
])).split()
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
TILES_BBOX = "5.8,45.7,10.6,47.9" # Schweiz mit etwas Rand
TILES_BBOX = "5.5,45.7,15.3,55.2" # Schweiz + Deutschland mit etwas Rand
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+8 -5
View File
@@ -111,6 +111,9 @@ class Feed:
self.st_dep.append(d)
self.st_dist.append(m)
self.off[n] = len(self.st_stop)
for ti in range(n): # gtfs.de liefert kein Fahrtziel: dann der letzte Halt
if not self.trip_headsign[ti] and self.off[ti + 1] > self.off[ti]:
self.trip_headsign[ti] = self.stop_name[self.st_stop[self.off[ti + 1] - 1]]
self.shape_index, self.shape_data = {}, None
if (path / "shapes.bin").exists() and (path / "shapes.bin").stat().st_size:
@@ -131,12 +134,12 @@ class Feed:
cd = path / "calendar_dates.txt"
if cd.exists():
with open(cd, encoding="utf-8-sig") as f:
assert next(f).strip().replace('"', "").startswith("service_id,date,exception_type")
h = next(f).strip().replace('"', "").split(",") # Spaltenreihenfolge ist je Quelle anders
si, di, ti_ = h.index("service_id"), h.index("date"), h.index("exception_type")
for line in f:
sid, date, typ = line.rstrip("\r\n").rsplit(",", 2)
date = date.strip('"')
if date in window:
self.exceptions.setdefault(date, {})[sid.strip('"')] = int(typ.strip('"'))
v = line.rstrip("\r\n").replace('"', "").split(",") # kurze Datei ohne Kommas in Werten
if v[di] in window:
self.exceptions.setdefault(v[di], {})[v[si]] = int(v[ti_])
self._day_cache = {}
self._geom_cache = {}
+138 -66
View File
@@ -1,9 +1,10 @@
"""Fahrplan-Update: holt den Schweizer GTFS-Fahrplan, filtert auf Bahn und rechnet mit pfaedle
die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich per systemd-Timer und
macht nichts, solange opentransportdata.swiss keinen neuen Fahrplan veröffentlicht hat.
"""Fahrplan-Update: holt für jede Quelle (Schweiz, Deutschland) den GTFS-Fahrplan, filtert auf Bahn
und rechnet mit pfaedle die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich
und macht für eine Quelle nichts, solange dort kein neuer Fahrplan veröffentlicht wurde.
python -m trainpulse.prepare # normaler Lauf
python -m trainpulse.prepare --zip X # lokale Datei statt Download (zum Testen)
python -m trainpulse.prepare # alle Quellen
python -m trainpulse.prepare --source de --force # eine Quelle neu aufbereiten
python -m trainpulse.prepare --source ch --zip X # lokale Datei statt Download (zum Testen)
"""
import argparse
import csv
@@ -39,10 +40,12 @@ def timetable_year(today: dt.date) -> int:
return today.year
def gtfs_url() -> str:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
return C.GTFS_URL or (
f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink")
def gtfs_url(src: str) -> str:
url = C.SOURCES[src]["gtfs_url"]
if src == "ch" and not url:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
return url
def download(url: str, dest: Path) -> None:
@@ -56,6 +59,19 @@ def download(url: str, dest: Path) -> None:
tmp.rename(dest)
def remote_id(src: str, url: str) -> tuple[str, str | None]:
"""Kennung der aktuell veröffentlichten Datei, ohne sie herunterzuladen: (Name, Datum JJJJMMTT oder None)."""
if src == "ch":
return latest_gtfs_name(url), None
r = httpx.head(url, follow_redirects=True, timeout=60, headers={"User-Agent": C.USER_AGENT})
r.raise_for_status()
lm = r.headers.get("last-modified")
if not lm:
raise RuntimeError(f"{url}: kein Last-Modified-Header")
date = dt.datetime.strptime(lm, "%a, %d %b %Y %H:%M:%S GMT").strftime("%Y%m%d")
return f"{src}-{lm}", date
def latest_gtfs_name(url: str) -> str:
# Der Permalink leitet auf .../gtfs_fp2026_YYYYMMDD.zip weiter. HEAD geht beim Speicher nicht,
# darum nur die erste Weiterleitung lesen – so sehen wir ohne 300-MB-Download, ob es Neues gibt.
@@ -83,26 +99,33 @@ def write_csv(path: Path, header, data) -> int:
return n
def filter_by_first_col(z: zipfile.ZipFile, name: str, keys: set, out: Path, collect: str | None = None):
"""Schnellfilter für die Riesendateien (stop_times.txt hat ~3.7 GB): vergleicht nur das erste Feld
als Rohtext, schreibt Treffer unverändert weg. Gibt (Anzahl, Werte der Spalte `collect`) zurück."""
def filter_by_col(z: zipfile.ZipFile, name: str, col: str, keys: set, out: Path, collect: str | None = None):
"""Filter für die Riesendateien (stop_times.txt hat mehrere GB): behält Zeilen, deren Spalte `col`
in `keys` ist, und schreibt sie unverändert weg. Steht `col` vorne, wird nur das erste Feld als
Rohtext verglichen (schnell). Gibt (Anzahl, Werte der Spalte `collect`) zurück."""
n, seen = 0, set()
with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f:
lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
header = next(csv.reader([lines.readline()]))
assert header[0] in ("trip_id", "service_id"), header
f.write(",".join(header) + "\n")
ci = header.index(collect) if collect else None
ki, ci = header.index(col), header.index(collect) if collect else None
for line in lines:
if line.split(",", 1)[0].strip('"') in keys:
f.write(line)
n += 1
if ci is not None:
seen.add(next(csv.reader([line]))[ci])
if ki == 0:
if line.split(",", 1)[0].strip('"') not in keys:
continue
row = next(csv.reader([line])) if ci is not None else None
else:
row = next(csv.reader([line]))
if row[ki] not in keys:
continue
f.write(line)
n += 1
if ci is not None:
seen.add(row[ci])
return n, seen
def filter_rail(zip_path: Path, out: Path) -> dict:
def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path)
@@ -119,20 +142,21 @@ def filter_rail(zip_path: Path, out: Path) -> dict:
if "frequencies.txt" in z.namelist():
fh, frd = rows(z, "frequencies.txt")
n = sum(1 for r in frd if r[0] in trip_ids)
n = sum(1 for r in frd if r[fh.index("trip_id")] in trip_ids)
if n:
log.warning("%d Bahnfahrten nur über frequencies.txt – werden ignoriert", n)
n_st, used_stops = filter_by_first_col(z, "stop_times.txt", trip_ids, out / "stop_times.txt", "stop_id")
n_st, used_stops = filter_by_col(z, "stop_times.txt", "trip_id", trip_ids, out / "stop_times.txt", "stop_id")
h, rd = rows(z, "stops.txt")
sid, par = h.index("stop_id"), h.index("parent_station")
stops = list(rd)
parents = {r[h.index("parent_station")] for r in stops if r[0] in used_stops}
write_csv(out / "stops.txt", h, (r for r in stops if r[0] in used_stops or r[0] in parents))
parents = {r[par] for r in stops if r[sid] in used_stops}
write_csv(out / "stops.txt", h, (r for r in stops if r[sid] in used_stops or r[sid] in parents))
for name in ("calendar.txt", "calendar_dates.txt"):
if name in z.namelist():
filter_by_first_col(z, name, service_ids, out / name)
filter_by_col(z, name, "service_id", service_ids, out / name)
for name in ("agency.txt", "feed_info.txt"):
if name in z.namelist():
h, rd = rows(z, name)
@@ -140,17 +164,40 @@ def filter_rail(zip_path: Path, out: Path) -> dict:
h, rd = rows(z, "feed_info.txt") if "feed_info.txt" in z.namelist() else ([], iter([]))
info = dict(zip(h, next(rd, [])))
return {"feed_version": info.get("feed_version") or zip_path.stem,
fv = info.get("feed_version", "")
return {"feed_version": fv if fv.isdigit() else (version or zip_path.stem), # gtfs.de: "latest-free"
"routes": len(routes), "trips": len(trips), "stop_times": n_st}
def ensure_osm() -> Path:
osm = C.DATA_DIR / "osm" / "region.osm.pbf"
if not osm.exists() or time.time() - osm.stat().st_mtime > 30 * 86400:
osm.parent.mkdir(parents=True, exist_ok=True)
log.info("Lade OSM-Daten %s", C.OSM_URL)
download(C.OSM_URL, osm)
return osm
"""Schienennetz aus OpenStreetMap für alle Länder: herunterladen, mit osmium auf Bahn-Objekte
filtern (Deutschland: ~4 GB -> wenige 100 MB) und zusammenführen. Monatlich erneuert."""
d = C.DATA_DIR / "osm"
rail, urls_file = d / "rail.osm.pbf", d / "urls.txt"
wanted = "\n".join(C.OSM_URLS)
if rail.exists() and time.time() - rail.stat().st_mtime < 30 * 86400 \
and urls_file.exists() and urls_file.read_text() == wanted:
return rail
d.mkdir(parents=True, exist_ok=True)
parts = []
for i, url in enumerate(C.OSM_URLS):
raw, part = d / f"download-{i}.osm.pbf", d / f"rail-{i}.osm.pbf"
log.info("Lade OSM-Daten %s", url)
download(url, raw)
log.info("Filtere Schienennetz …")
subprocess.run([C.OSMIUM, "tags-filter", str(raw), "nwr/railway", "nwr/public_transport", "r/route",
"-o", str(part), "--overwrite"], check=True)
raw.unlink() # spart Platz
parts.append(str(part))
tmp = d / "rail.tmp.osm.pbf"
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(tmp), "--overwrite"], check=True)
tmp.rename(rail)
urls_file.write_text(wanted)
for p in parts:
Path(p).unlink()
for old in d.glob("region.osm.pbf"): # Datei der alten Version (nur Schweiz)
old.unlink()
return rail
def run_pfaedle(work: Path, rail: Path) -> Path | None:
@@ -202,9 +249,12 @@ def pack_shapes(d: Path) -> None:
def ensure_tiles() -> None:
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen."""
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen
(erneut, wenn sich der Ausschnitt geändert hat)."""
dest = C.DATA_DIR / "tiles" / "region.pmtiles"
if dest.exists() and dest.stat().st_size:
info = C.DATA_DIR / "tiles" / "region.txt"
wanted = f"{C.TILES_BBOX} z{C.TILES_MAXZOOM}"
if dest.exists() and dest.stat().st_size and info.exists() and info.read_text() == wanted:
return
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".tmp")
@@ -212,55 +262,48 @@ def ensure_tiles() -> None:
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
log.info("Lade Kartenkacheln vom %s …", d)
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
f"--bbox={C.TILES_BBOX}", "--maxzoom=15"])
f"--bbox={C.TILES_BBOX}", f"--maxzoom={C.TILES_MAXZOOM}"])
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
tmp.rename(dest)
info.write_text(wanted)
return
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
def publish(src: Path, meta: dict) -> None:
def publish(source: str, src: Path, meta: dict) -> None:
(src / "meta.json").write_text(json.dumps(meta))
dest = C.FEEDS_DIR / meta["feed_version"]
base = C.FEEDS_DIR / source
base.mkdir(parents=True, exist_ok=True)
dest = base / meta["feed_version"]
if dest.exists():
shutil.rmtree(dest)
src.rename(dest) # gleiche Partition -> atomar; der Server sieht nur fertige Ordner
for old in sorted(p for p in C.FEEDS_DIR.iterdir() if p.is_dir())[:-3]:
for old in sorted(p for p in base.iterdir() if p.is_dir())[:-2]:
shutil.rmtree(old)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--zip", type=Path, help="lokale GTFS-Zip statt Download")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
if not args.zip:
ensure_tiles()
state_file = C.DATA_DIR / "state.json"
state = json.loads(state_file.read_text()) if state_file.exists() else {}
def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool = False) -> None:
work = C.DATA_DIR / "work"
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
if args.zip:
zip_path, name = args.zip, args.zip.name
version = None
if zip_path:
name = zip_path.name
else:
url = gtfs_url()
name = latest_gtfs_name(url)
if name == state.get("last_file") and not args.force:
log.info("Kein neuer Fahrplan (%s)", name)
url = gtfs_url(source)
name, version = remote_id(source, url)
if name == state.get(source) and not force:
log.info("[%s] Kein neuer Fahrplan (%s)", source, name)
return
zip_path = work / name
log.info("Lade %s", name)
zip_path = work / "gtfs.zip"
log.info("[%s] Lade Fahrplan %s", source, name)
download(url, zip_path)
t = time.time()
meta = filter_rail(zip_path, work / "rail")
log.info("Bahn-Filter: %s in %.0f s", meta, time.time() - t)
if not args.zip:
meta = filter_rail(zip_path, work / "rail", version)
log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
if zip_path.parent == work:
zip_path.unlink()
shaped = run_pfaedle(work, work / "rail")
@@ -269,12 +312,41 @@ def main() -> None:
pack_shapes(shaped)
meta["source_file"] = name
meta["prepared_at"] = int(time.time())
publish(shaped or work / "rail", meta)
state["last_file"] = name
state_file.write_text(json.dumps(state))
publish(source, shaped or work / "rail", meta)
shutil.rmtree(work, ignore_errors=True)
log.info("Fahrplan %s bereit", meta["feed_version"])
state[source] = name
log.info("[%s] Fahrplan %s bereit", source, meta["feed_version"])
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--source", choices=list(C.SOURCES), help="nur diese Quelle (Standard: alle aktiven)")
ap.add_argument("--zip", type=Path, help="lokale GTFS-Zip statt Download (nur mit --source)")
ap.add_argument("--force", action="store_true")
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
if args.zip and not args.source:
ap.error("--zip braucht --source")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
for p in C.FEEDS_DIR.iterdir(): # Ordner der alten Version (feeds/<version>) und abgeschaltete Quellen
if p.is_dir() and p.name not in C.SOURCES:
shutil.rmtree(p)
if not args.zip:
ensure_tiles()
state_file = C.DATA_DIR / "state.json"
state = json.loads(state_file.read_text()) if state_file.exists() else {}
failed = False
for source in [args.source] if args.source else C.ENABLED:
try:
prepare(source, state, args.zip, args.force)
except Exception: # eine kaputte Quelle soll die anderen nicht aufhalten
log.exception("[%s] Aufbereitung fehlgeschlagen", source)
failed = True
state_file.write_text(json.dumps(state))
if failed:
raise SystemExit(1)
if __name__ == "__main__":
+5 -3
View File
@@ -34,8 +34,10 @@ def parse(data: bytes, known_trips: dict):
return version, out
def fetch() -> bytes:
r = httpx.get(C.RT_URL, follow_redirects=True, timeout=30, headers={
"Authorization": f"Bearer {C.RT_KEY}", "User-Agent": C.USER_AGENT})
def fetch(url: str, key: str = "") -> bytes:
headers = {"User-Agent": C.USER_AGENT}
if key:
headers["Authorization"] = f"Bearer {key}"
r = httpx.get(url, follow_redirects=True, timeout=30, headers=headers)
r.raise_for_status()
return r.content
+93 -56
View File
@@ -15,7 +15,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from . import config as C
from . import realtime
from . import border, realtime
from .feed import TZ, Feed
log = logging.getLogger("trainpulse")
@@ -23,13 +23,25 @@ STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Bro
WEB = Path(__file__).resolve().parent.parent / "web"
class Source:
"""Ein Fahrplan (Schweiz, Deutschland, …) mit seinem Echtzeit-Feed."""
def __init__(self, name: str):
self.name, self.cfg = name, C.SOURCES[name]
self.feed: Feed | None = None
self.rt: dict = {}
self.rt_version: str | None = None
self.rt_at: float | None = None
self.rt_error: str | None = None
self.rt_tried_at: float | None = None
@property
def has_rt(self) -> bool:
return bool(self.cfg["rt_url"]) and (bool(self.cfg["rt_key"]) or not self.cfg["needs_key"])
class State:
feed: Feed | None = None
rt: dict = {}
rt_version: str | None = None
rt_at: float | None = None
rt_error: str | None = None
rt_tried_at: float | None = None
sources = {name: Source(name) for name in C.ENABLED}
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
vehicles_gz: bytes = gzip.compress(vehicles)
count = 0
@@ -39,62 +51,78 @@ class State:
S = State()
def loop(every: float, fn):
def loop(every: float, fn, *args):
def run():
while True:
t = time.time()
try:
fn()
fn(*args)
except Exception:
log.exception("Fehler in %s", fn.__name__)
log.exception("Fehler in %s %s", fn.__name__, args)
time.sleep(max(1.0, every - (time.time() - t)))
threading.Thread(target=run, daemon=True, name=fn.__name__).start()
def watch_feeds():
"""Lädt den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (die schalten
erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
ready = sorted(m.parent.name for m in C.FEEDS_DIR.glob("*/meta.json"))
if not ready:
return
want = S.rt_version if S.rt_version in ready else ready[-1]
"""Lädt je Quelle den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (Schweiz:
die schalten erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
today = dt.datetime.now(TZ).date()
if S.feed and S.feed.version == want and S.feed.covers(today + dt.timedelta(days=1)):
return
log.info("Lade Fahrplan %s …", want)
t = time.time()
S.feed = Feed(C.FEEDS_DIR / want)
log.info("Fahrplan %s geladen in %.0f s (%d Fahrten)", want, time.time() - t, len(S.feed.trip_ids))
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
poll_realtime()
except Exception:
log.exception("Erster Echtzeit-Abruf fehlgeschlagen")
compute_positions()
for src in S.sources.values():
ready = sorted(m.parent.name for m in (C.FEEDS_DIR / src.name).glob("*/meta.json"))
if not ready:
continue
want = src.rt_version if src.rt_version in ready else ready[-1]
if src.feed and src.feed.version == want and src.feed.covers(today + dt.timedelta(days=1)):
continue
log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time()
src.feed = Feed(C.FEEDS_DIR / src.name / want)
log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t,
len(src.feed.trip_ids))
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
poll_realtime(src)
except Exception:
log.exception("[%s] Erster Echtzeit-Abruf fehlgeschlagen", src.name)
compute_positions()
def poll_realtime():
if not C.RT_KEY or not S.feed:
def poll_realtime(src: Source):
if not src.has_rt or not src.feed:
return
try:
S.rt_version, S.rt = realtime.parse(realtime.fetch(), S.feed.trip_index)
S.rt_at, S.rt_error = time.time(), None
src.rt_version, src.rt = realtime.parse(realtime.fetch(src.cfg["rt_url"], src.cfg["rt_key"]),
src.feed.trip_index)
src.rt_at, src.rt_error = time.time(), None
except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen
S.rt_error = str(e)
if S.rt_at and time.time() - S.rt_at > 600:
S.rt = {}
src.rt_error = str(e)
if src.rt_at and time.time() - src.rt_at > 600:
src.rt = {}
raise
finally:
S.rt_tried_at = time.time()
src.rt_tried_at = time.time()
def positions(src: Source, t: float) -> list:
show = border.SHOW.get(src.name) if len(S.sources) > 1 else None
feats = []
for f in src.feed.vehicles(t, src.rt):
if show and not show(*f["geometry"]["coordinates"]):
continue # gehört zum Fahrplan des Nachbarlandes
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
feats.append(f)
return feats
def compute_positions():
if not S.feed:
return
now = time.time()
feats = S.feed.vehicles(now, S.rt)
future = [{f["properties"]["id"]: f["geometry"]["coordinates"] + [f["properties"]["heading"]]
for f in S.feed.vehicles(now + STEP * k, S.rt)}
for k in range(1, STEPS + 1)]
feats, future = [], [{} for _ in range(STEPS)]
for src in list(S.sources.values()):
if not src.feed:
continue
feats += positions(src, now)
for k in range(STEPS):
future[k].update({f"{src.name}/{f['properties']['id']}": f["geometry"]["coordinates"] + [f["properties"]["heading"]]
for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)})
for f in feats:
path = []
for pos in future:
@@ -114,7 +142,8 @@ async def lifespan(app):
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
loop(60, watch_feeds)
loop(C.RT_INTERVAL, poll_realtime)
for src in S.sources.values():
loop(src.cfg["rt_interval"], poll_realtime, src)
loop(3, compute_positions)
yield
@@ -130,28 +159,36 @@ def vehicles(request: Request):
return Response(S.vehicles, media_type="application/json", headers=headers)
@app.get("/api/trip/{date}/{ti}")
def trip(date: str, ti: int):
f = S.feed
@app.get("/api/trip/{source}/{date}/{ti}")
def trip(source: str, date: str, ti: int):
src = S.sources.get(source)
f = src.feed if src else None
if not f or not (0 <= ti < len(f.trip_ids)) or not (len(date) == 8 and date.isdigit()):
raise HTTPException(404)
return f.trip_detail(date, ti, time.time(), S.rt)
return f.trip_detail(date, ti, time.time(), src.rt)
@app.get("/api/status")
def status():
f = S.feed
loaded = [s for s in S.sources.values() if s.feed]
rt = [s for s in loaded if s.has_rt]
ok = [s for s in rt if not s.rt_error and s.rt_at and time.time() - s.rt_at < 180]
return {
"feed_version": f.version if f else None,
"shapes": f.meta.get("shapes") if f else None,
"realtime": bool(C.RT_KEY),
"rt_version": S.rt_version,
"rt_age": round(time.time() - S.rt_at) if S.rt_at else None,
"rt_error": S.rt_error,
"rt_trips": len(S.rt),
"vehicles": S.count,
"loaded": bool(loaded),
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
"ready": bool(f and S.computed_at and (not C.RT_KEY or (S.rt_tried_at and S.computed_at >= S.rt_tried_at))),
"ready": bool(loaded and S.computed_at and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
"vehicles": S.count,
"rt_ok": bool(ok) and len(ok) == len(loaded),
"rt_some": bool(ok),
"sources": {s.name: {
"feed_version": s.feed.version if s.feed else None,
"shapes": s.feed.meta.get("shapes") if s.feed else None,
"realtime": s.has_rt,
"rt_version": s.rt_version,
"rt_age": round(time.time() - s.rt_at) if s.rt_at else None,
"rt_error": s.rt_error,
"rt_trips": len(s.rt),
} for s in S.sources.values()},
}
+8 -6
View File
@@ -25,8 +25,8 @@ const delayColor = ["case",
const map = new maplibregl.Map({
container: "map",
center: [8.23, 46.82],
zoom: 7.4,
center: [9.6, 49.2], // Deutschland + Schweiz
zoom: 5.6,
hash: true,
style: {
version: 8,
@@ -50,7 +50,8 @@ const map = new maplibregl.Map({
route: { type: "geojson", data: EMPTY },
trains: {
type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>',
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0)',
},
},
layers: [
@@ -234,7 +235,7 @@ async function refreshStatus() {
try {
const s = await (await fetch("api/status")).json();
if (!s.ready) {
loading.textContent = s.feed_version ? "Züge werden geladen …"
loading.textContent = s.loaded ? "Züge werden geladen …"
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 15–20 Minuten.";
loading.hidden = false;
return;
@@ -244,8 +245,9 @@ async function refreshStatus() {
await refreshTrains();
loading.hidden = true;
}
const rtOk = s.realtime && !s.rt_error && s.rt_age !== null && s.rt_age < 180;
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : " · Verspätungen zurzeit nicht verfügbar");
const rtOk = s.rt_ok;
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : s.rt_some
? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
el.className = rtOk ? "" : "warn";
} catch (e) {
loading.textContent = "Keine Verbindung zum Server …";