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
View File
+16
View File
@@ -0,0 +1,16 @@
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
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)
OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf")
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+293
View File
@@ -0,0 +1,293 @@
"""Hält einen aufbereiteten Bahn-Fahrplan im Speicher und berechnet daraus Zugpositionen
entlang der Strecke (Fahrplan + Echtzeit-Verspätung -> Position auf dem Streckenverlauf)."""
import csv
import datetime as dt
import json
import math
from array import array
from bisect import bisect_right
from pathlib import Path
from zoneinfo import ZoneInfo
from . import config as C
TZ = ZoneInfo(C.TZ)
CAL_DAYS = 14 # so viele Tage Ausnahmen aus calendar_dates laden (die Datei ist riesig)
def hms(s: str) -> int:
h, m, sec = s.split(":")
return int(h) * 3600 + int(m) * 60 + int(sec)
def day_start(d: dt.date) -> float:
# GTFS-Zeiten zählen ab "Mittag minus 12 h" – stimmt auch an Tagen mit Zeitumstellung
return dt.datetime(d.year, d.month, d.day, 12, tzinfo=TZ).timestamp() - 43200
def read(path: Path):
with open(path, encoding="utf-8-sig", newline="") as f:
r = csv.reader(f)
h = {k: i for i, k in enumerate(next(r))}
for row in r:
yield h, row
def xy(lon, lat, lat0): # lokale Projektion in Metern, reicht für Abstände entlang einer Strecke
return lon * 111320 * math.cos(math.radians(lat0)), lat * 110540
class Feed:
def __init__(self, path: Path, today: dt.date | None = None):
self.path = path
self.meta = json.loads((path / "meta.json").read_text())
self.version = self.meta["feed_version"]
self.stop_index, self.stop_name, self.stop_lat, self.stop_lon, self.stop_platform = {}, [], [], [], []
for h, r in read(path / "stops.txt"):
self.stop_index[r[h["stop_id"]]] = len(self.stop_name)
self.stop_name.append(r[h["stop_name"]])
self.stop_lat.append(float(r[h["stop_lat"]]))
self.stop_lon.append(float(r[h["stop_lon"]]))
self.stop_platform.append(r[h["platform_code"]] if "platform_code" in h else "")
agencies = {r[h["agency_id"]]: r[h["agency_name"]] for h, r in read(path / "agency.txt")}
self.routes = {}
for h, r in read(path / "routes.txt"):
self.routes[r[h["route_id"]]] = {
"line": r[h["route_short_name"]] or r[h.get("route_long_name", 0)],
"cat": r[h["route_desc"]] if "route_desc" in h else "",
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" in h else "",
}
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
self.trip_headsign, self.trip_number, self.trip_shape = [], [], []
for h, r in read(path / "trips.txt"):
self.trip_index[r[h["trip_id"]]] = len(self.trip_route)
self.trip_ids.append(r[h["trip_id"]])
self.trip_route.append(r[h["route_id"]])
self.trip_service.append(r[h["service_id"]])
self.trip_headsign.append(r[h["trip_headsign"]] if "trip_headsign" in h else "")
self.trip_number.append(r[h["trip_short_name"]] if "trip_short_name" in h else "")
self.trip_shape.append(r[h["shape_id"]] if "shape_id" in h else "")
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
n = len(self.trip_route)
self.off = array("i", [0]) * (n + 1)
self.st_stop, self.st_seq, self.st_arr, self.st_dep = array("i"), array("i"), array("i"), array("i")
rows = {}
for h, r in read(path / "stop_times.txt"):
rows.setdefault(self.trip_index[r[h["trip_id"]]], []).append(
(int(r[h["stop_sequence"]]), self.stop_index[r[h["stop_id"]]], hms(r[h["arrival_time"]]), hms(r[h["departure_time"]])))
for ti in range(n):
self.off[ti] = len(self.st_stop)
for seq, s, a, d in sorted(rows.pop(ti, ())):
self.st_seq.append(seq)
self.st_stop.append(s)
self.st_arr.append(a)
self.st_dep.append(d)
self.off[n] = len(self.st_stop)
self.shapes = {}
if (path / "shapes.txt").exists():
pts = {}
for h, r in read(path / "shapes.txt"):
pts.setdefault(r[h["shape_id"]], []).append(
(int(r[h["shape_pt_sequence"]]), float(r[h["shape_pt_lon"]]), float(r[h["shape_pt_lat"]])))
for sid, p in pts.items():
p.sort()
self.shapes[sid] = [(lon, lat) for _, lon, lat in p]
self.calendar = {}
for h, r in read(path / "calendar.txt"):
self.calendar[r[h["service_id"]]] = (
[r[h[d]] == "1" for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")],
r[h["start_date"]], r[h["end_date"]])
today = today or dt.datetime.now(TZ).date()
self.cal_from = today - dt.timedelta(days=1)
self.cal_until = today + dt.timedelta(days=CAL_DAYS)
window = {(self.cal_from + dt.timedelta(days=i)).strftime("%Y%m%d") for i in range(CAL_DAYS + 2)}
self.exceptions = {} # datum -> {service_id: 1 (fährt) | 2 (fährt nicht)}
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")
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('"'))
self._day_cache = {}
self._geom_cache = {}
# ---------- Kalender ----------
def trips_on(self, d: dt.date):
"""Fahrten eines Betriebstags, sortiert nach Abfahrt: (start, ende, trip_index)."""
if d not in self._day_cache:
ds = d.strftime("%Y%m%d")
exc = self.exceptions.get(ds, {})
active = {sid for sid, (days, a, b) in self.calendar.items() if a <= ds <= b and days[d.weekday()]}
active |= {sid for sid, t in exc.items() if t == 1}
active -= {sid for sid, t in exc.items() if t == 2}
lst = sorted((self.st_dep[self.off[ti]], self.st_arr[self.off[ti + 1] - 1], ti)
for ti, sid in enumerate(self.trip_service)
if sid in active and self.off[ti + 1] - self.off[ti] >= 2)
if len(self._day_cache) > 4:
self._day_cache.clear()
self._day_cache[d] = lst
return self._day_cache[d]
def covers(self, d: dt.date) -> bool:
return self.cal_from <= d <= self.cal_until
# ---------- Geometrie ----------
def geometry(self, ti: int):
"""Streckenverlauf einer Fahrt: Punkte, kumulierte Meter, Meter-Position jedes Halts."""
o0, o1 = self.off[ti], self.off[ti + 1]
stops = tuple(self.st_stop[o0:o1])
key = (self.trip_shape[ti], stops)
g = self._geom_cache.get(key)
if g:
return g
shape = self.shapes.get(self.trip_shape[ti])
pts = shape or [(self.stop_lon[s], self.stop_lat[s]) for s in stops]
lat0 = pts[0][1]
P = [xy(lon, lat, lat0) for lon, lat in pts]
cum = [0.0]
for (x1, y1), (x2, y2) in zip(P, P[1:]):
cum.append(cum[-1] + math.hypot(x2 - x1, y2 - y1))
if shape:
stop_d, j = [], 0
for s in stops:
px, py = xy(self.stop_lon[s], self.stop_lat[s], lat0)
best = (float("inf"), cum[j], j)
for k in range(j, len(P) - 1):
(x1, y1), (x2, y2) = P[k], P[k + 1]
dx, dy = x2 - x1, y2 - y1
seg = dx * dx + dy * dy
t = 0.0 if seg == 0 else max(0.0, min(1.0, ((px - x1) * dx + (py - y1) * dy) / seg))
dist = math.hypot(x1 + t * dx - px, y1 + t * dy - py)
if dist < best[0]:
best = (dist, cum[k] + t * (cum[k + 1] - cum[k]), k)
elif best[0] < 100 and dist > best[0] + 1000:
break # nächster Halt gefunden, nicht bis ans Streckenende weitersuchen
stop_d.append(best[1])
j = best[2]
else:
stop_d = cum
g = (pts, cum, stop_d)
self._geom_cache[key] = g
return g
@staticmethod
def point_at(g, d: float):
pts, cum, _ = g
k = max(0, min(bisect_right(cum, d) - 1, len(pts) - 2))
seg = cum[k + 1] - cum[k]
t = 0.0 if seg <= 0 else (d - cum[k]) / seg
(x1, y1), (x2, y2) = pts[k], pts[k + 1]
return round(x1 + t * (x2 - x1), 6), round(y1 + t * (y2 - y1), 6)
# ---------- Echtzeit ----------
def times(self, ti: int, ds: float, upd):
"""Ankunft/Abfahrt je Halt (Sekunden ab Betriebstag), mit Verspätungen aus GTFS-RT.
Eine Verspätung gilt für alle folgenden Halte, bis eine neue gemeldet wird."""
o0, o1 = self.off[ti], self.off[ti + 1]
arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1])
if not upd:
return arr, dep
seqs = self.st_seq[o0:o1]
by_i = {}
for u in upd:
i = bisect_right(seqs, u[0]) - 1
if i >= 0 and seqs[i] == u[0]:
by_i[i] = u
cur, prev = None, -10 ** 9
for i in range(len(arr)):
u = by_i.get(i)
if u and u[1] is not None:
a = u[1] - ds if u[2] else arr[i] + u[1]
cur = a - arr[i]
else:
a = arr[i] + (cur or 0)
if u and u[3] is not None:
d = u[3] - ds if u[4] else dep[i] + u[3]
cur = d - dep[i]
else:
d = dep[i] + (cur or 0)
a = max(a, prev)
d = max(d, a)
arr[i], dep[i], prev = a, d, d
return arr, dep
# ---------- Positionen ----------
def vehicles(self, now: float, rt: dict):
feats = []
today = dt.datetime.fromtimestamp(now, TZ).date()
for d in (today - dt.timedelta(days=1), today):
ds, dstr = day_start(d), d.strftime("%Y%m%d")
t = now - ds
for start, end, ti in self.trips_on(d):
if start > t + 600:
break # Liste ist nach Abfahrt sortiert
if end + 7200 < t: # grosszügig, damit verspätete Züge nicht verschwinden
continue
r = rt.get((self.trip_ids[ti], dstr))
if r and r[0]:
continue # ausgefallen
arr, dep = self.times(ti, ds, r[1] if r else None)
if not (dep[0] <= t <= arr[-1]):
continue
i = min(bisect_right(dep, t), len(dep) - 1) # Zug ist zwischen Halt i-1 und i (oder steht an Halt i)
g = self.geometry(ti)
sd = g[2]
o = self.off[ti]
if t >= arr[i]: # steht am Halt i
dist, delay = sd[i], dep[i] - self.st_dep[o + i]
else:
f = (t - dep[i - 1]) / max(1, arr[i] - dep[i - 1])
dist, delay = sd[i - 1] + f * (sd[i] - sd[i - 1]), arr[i] - self.st_arr[o + i]
lon, lat = self.point_at(g, dist)
route = self.routes[self.trip_route[ti]]
feats.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"],
"nr": self.trip_number[ti], "to": self.trip_headsign[ti],
"delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
},
})
return feats
def trip_detail(self, date: str, ti: int, now: float, rt: dict):
d = dt.datetime.strptime(date, "%Y%m%d").date()
ds = day_start(d)
r = rt.get((self.trip_ids[ti], date))
arr, dep = self.times(ti, ds, r[1] if r else None)
o0, o1 = self.off[ti], self.off[ti + 1]
def hhmm(sec):
return dt.datetime.fromtimestamp(ds + sec, TZ).strftime("%H:%M")
stops = []
for k, i in enumerate(range(o0, o1)):
s = self.st_stop[i]
stops.append({
"name": self.stop_name[s], "platform": self.stop_platform[s],
"arr": hhmm(self.st_arr[i]) if k > 0 else None,
"dep": hhmm(self.st_dep[i]) if i < o1 - 1 else None,
"arr_delay": arr[k] - self.st_arr[i] if r else None,
"dep_delay": dep[k] - self.st_dep[i] if r else None,
"passed": now - ds > dep[k],
})
route = self.routes[self.trip_route[ti]]
return {"line": route["line"], "cat": route["cat"], "agency": route["agency"],
"nr": self.trip_number[ti], "to": self.trip_headsign[ti],
"realtime": bool(r), "canceled": bool(r and r[0]),
"stops": stops, "shape": self.geometry(ti)[0]}
+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()
+41
View File
@@ -0,0 +1,41 @@
"""GTFS-RT Trip Updates von opentransportdata.swiss holen und auf das Nötigste eindampfen."""
import httpx
from google.transit import gtfs_realtime_pb2 as pb
from . import config as C
CANCELED = pb.TripDescriptor.CANCELED
def parse(data: bytes, known_trips: dict):
"""-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, ank, ank_absolut, abf, abf_absolut), ...])})
ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe."""
feed = pb.FeedMessage()
feed.ParseFromString(data)
out = {}
for e in feed.entity:
if not e.HasField("trip_update"):
continue
tu = e.trip_update
if tu.trip.trip_id not in known_trips:
continue # nicht Bahn
upd = []
for s in tu.stop_time_update:
ev = []
for name in ("arrival", "departure"):
if s.HasField(name):
x = getattr(s, name)
ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False]
else:
ev += [None, False]
upd.append((s.stop_sequence, *ev))
out[(tu.trip.trip_id, tu.trip.start_date)] = (tu.trip.schedule_relationship == CANCELED, upd)
version = feed.header.feed_version if feed.header.HasField("feed_version") else None
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})
r.raise_for_status()
return r.content
+135
View File
@@ -0,0 +1,135 @@
"""Webserver: liefert die Karte aus und berechnet laufend die Zugpositionen.
uvicorn trainpulse.server:app --host 0.0.0.0 --port 8080
"""
import datetime as dt
import gzip
import json
import logging
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from . import config as C
from . import realtime
from .feed import TZ, Feed
log = logging.getLogger("trainpulse")
WEB = Path(__file__).resolve().parent.parent / "web"
class State:
feed: Feed | None = None
rt: dict = {}
rt_version: str | None = None
rt_at: float | None = None
rt_error: str | None = None
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
vehicles_gz: bytes = gzip.compress(vehicles)
count = 0
computed_at: float | None = None
S = State()
def loop(every: float, fn):
def run():
while True:
t = time.time()
try:
fn()
except Exception:
log.exception("Fehler in %s", fn.__name__)
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]
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))
def poll_realtime():
if not C.RT_KEY or not S.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
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 = {}
raise
def compute_positions():
if not S.feed:
return
feats = S.feed.vehicles(time.time(), S.rt)
S.vehicles = json.dumps({"type": "FeatureCollection", "features": feats}, separators=(",", ":")).encode()
S.vehicles_gz = gzip.compress(S.vehicles, 5)
S.count, S.computed_at = len(feats), time.time()
@asynccontextmanager
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)
loop(3, compute_positions)
yield
app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None)
@app.get("/api/vehicles")
def vehicles(request: Request):
headers = {"Cache-Control": "no-store"}
if "gzip" in request.headers.get("accept-encoding", ""):
return Response(S.vehicles_gz, media_type="application/json", headers={**headers, "Content-Encoding": "gzip"})
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
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)
@app.get("/api/status")
def status():
f = S.feed
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,
}
app.mount("/tiles", StaticFiles(directory=C.DATA_DIR / "tiles", check_dir=False), name="tiles") # PMTiles, Range-Requests
app.mount("/", StaticFiles(directory=WEB, html=True), name="web")