Files
Trainpulse/trainpulse/feed.py
T
GuidoandClaude Opus 5.5 0cd2c3951a Streckenverläufe kompakt speichern und nur bei Bedarf lesen
pfaedle erzeugt für die Schweiz eine 2.6-GB-shapes.txt; die wird jetzt
in shapes.bin (670 MB, mmap) umgewandelt. Halt-Positionen kommen aus
shape_dist_traveled. RAM-Empfehlung im README ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-21 15:07:14 +02:00

284 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
import mmap
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")
self.st_dist = array("f") # Meter entlang des Streckenverlaufs (von pfaedle), -1 = unbekannt
rows = {}
for h, r in read(path / "stop_times.txt"):
dist = r[h["shape_dist_traveled"]] if "shape_dist_traveled" in h else ""
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"]]), float(dist) if dist else -1.0))
for ti in range(n):
self.off[ti] = len(self.st_stop)
for seq, s, a, d, m 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.st_dist.append(m)
self.off[n] = len(self.st_stop)
self.shape_index, self.shape_data = {}, None
if (path / "shapes.bin").exists() and (path / "shapes.bin").stat().st_size:
self.shape_index = json.loads((path / "shapes.json").read_text())
with open(path / "shapes.bin", "rb") as f:
self.shape_data = memoryview(mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)).cast("f")
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.
Ohne (brauchbaren) Streckenverlauf: Luftlinie zwischen den Halten."""
o0, o1 = self.off[ti], self.off[ti + 1]
key = (self.trip_shape[ti], tuple(self.st_stop[o0:o1]))
g = self._geom_cache.get(key)
if g:
return g
shp = self.shape_index.get(self.trip_shape[ti])
stop_d = list(self.st_dist[o0:o1])
if shp and shp[1] >= 2 and min(stop_d) >= 0:
v = self.shape_data[shp[0] * 3:(shp[0] + shp[1]) * 3].tolist()
pts, cum = list(zip(v[0::3], v[1::3])), v[2::3]
else:
pts = [(self.stop_lon[s], self.stop_lat[s]) for s in self.st_stop[o0:o1]]
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))
stop_d = cum
if len(self._geom_cache) > 20000:
self._geom_cache.clear()
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]}