TrainPulse: Live-Zugkarte Schweiz (Grundgerüst)

Fahrplan-Aufbereitung (Bahnfilter + pfaedle), GTFS-RT-Abruf,
Positionsberechnung entlang der Strecke, Weboberfläche mit
Karte/Satellit, Proxmox-LXC-Installation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Guido
2026-10-21 14:59:08 +02:00
co-authored by Claude Opus 5.5
commit d92dac4a00
17 changed files with 1292 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
"""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.
python -m trainpulse.prepare # normaler Lauf
python -m trainpulse.prepare --zip X # lokale Datei statt Download (zum Testen)
"""
import argparse
import csv
import datetime as dt
import io
import json
import logging
import shutil
import subprocess
import time
import zipfile
from pathlib import Path
import httpx
from . import config as C
log = logging.getLogger("prepare")
def is_rail(route_type: str) -> bool:
t = int(route_type)
return t == 2 or 100 <= t <= 199 # GTFS + "extended route types" (Schweiz nutzt 100–117)
def timetable_year(today: dt.date) -> int:
# Fahrplanwechsel ist am 2. Sonntag im Dezember
if today.month == 12:
first = dt.date(today.year, 12, 1)
if today >= first + dt.timedelta(days=(6 - first.weekday()) % 7 + 7):
return today.year + 1
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 download(url: str, dest: Path) -> None:
tmp = dest.with_suffix(dest.suffix + ".part")
with httpx.stream("GET", url, follow_redirects=True, timeout=120,
headers={"User-Agent": C.USER_AGENT}) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_bytes(1 << 20):
f.write(chunk)
tmp.rename(dest)
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.
r = httpx.get(url, follow_redirects=False, timeout=60, headers={"User-Agent": C.USER_AGENT})
if r.status_code not in (301, 302, 303, 307, 308):
r.raise_for_status()
raise RuntimeError(f"Keine Weiterleitung von {url} (Status {r.status_code})")
return r.headers["location"].rsplit("/", 1)[-1].split("?")[0]
def rows(z: zipfile.ZipFile, name: str):
reader = csv.reader(io.TextIOWrapper(z.open(name), encoding="utf-8-sig", newline=""))
header = next(reader)
return header, reader
def write_csv(path: Path, header, data) -> int:
n = 0
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(header)
for row in data:
w.writerow(row)
n += 1
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."""
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
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])
return n, seen
def filter_rail(zip_path: Path, out: Path) -> dict:
out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path)
h, rd = rows(z, "routes.txt")
routes = [r for r in rd if is_rail(r[h.index("route_type")])]
write_csv(out / "routes.txt", h, routes)
route_ids = {r[h.index("route_id")] for r in routes}
h, rd = rows(z, "trips.txt")
trips = [r for r in rd if r[h.index("route_id")] in route_ids]
write_csv(out / "trips.txt", h, trips)
trip_ids = {r[h.index("trip_id")] for r in trips}
service_ids = {r[h.index("service_id")] for r in trips}
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)
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")
h, rd = rows(z, "stops.txt")
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))
for name in ("calendar.txt", "calendar_dates.txt"):
if name in z.namelist():
filter_by_first_col(z, name, service_ids, out / name)
for name in ("agency.txt", "feed_info.txt"):
if name in z.namelist():
h, rd = rows(z, name)
write_csv(out / name, h, rd)
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,
"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
def run_pfaedle(work: Path, rail: Path) -> Path | None:
if not shutil.which(C.PFAEDLE):
log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen")
return None
osm = ensure_osm()
log.info("pfaedle rechnet Streckenverläufe …")
t = time.time()
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail", "-x", str(osm), str(rail)], cwd=work)
out = work / "gtfs-out"
if res.returncode != 0 or not (out / "shapes.txt").exists():
log.error("pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", res.returncode)
return None
log.info("pfaedle fertig in %.0f s", time.time() - t)
return out
def publish(src: Path, meta: dict) -> None:
(src / "meta.json").write_text(json.dumps(meta))
dest = C.FEEDS_DIR / 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]:
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)
state_file = C.DATA_DIR / "state.json"
state = json.loads(state_file.read_text()) if state_file.exists() else {}
work = C.DATA_DIR / "work"
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
if args.zip:
zip_path, name = args.zip, args.zip.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)
return
zip_path = work / name
log.info("Lade %s", 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:
zip_path.unlink()
shaped = run_pfaedle(work, work / "rail")
meta["shapes"] = shaped is not None
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))
shutil.rmtree(work, ignore_errors=True)
log.info("Fahrplan %s bereit", meta["feed_version"])
if __name__ == "__main__":
main()