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,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]}
|
||||
Reference in New Issue
Block a user