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:
@@ -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)"
|
||||
@@ -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]}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user