WIP: Deutschland als zweite Datenquelle (gtfs.de)

- mehrere Quellen (ch, de) mit je eigenem Fahrplan und Echtzeit-Feed
- OSM CH+DE, mit osmium auf Schienennetz gefiltert, für pfaedle
- Landesgrenze CH gegen doppelte grenzüberschreitende Züge
- Import robust gegen andere Spaltenreihenfolge (gtfs.de)
- Kacheln für CH+DE (Zoom 14)

Noch nicht vollständig getestet (pfaedle auf Deutschland läuft).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-21 21:27:54 +02:00
parent 2e3c10a6c4
commit 2a4962c379
11 changed files with 370 additions and 168 deletions
+93 -56
View File
@@ -15,7 +15,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.staticfiles import StaticFiles
from . import config as C
from . import realtime
from . import border, realtime
from .feed import TZ, Feed
log = logging.getLogger("trainpulse")
@@ -23,13 +23,25 @@ STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Bro
WEB = Path(__file__).resolve().parent.parent / "web"
class Source:
"""Ein Fahrplan (Schweiz, Deutschland, …) mit seinem Echtzeit-Feed."""
def __init__(self, name: str):
self.name, self.cfg = name, C.SOURCES[name]
self.feed: Feed | None = None
self.rt: dict = {}
self.rt_version: str | None = None
self.rt_at: float | None = None
self.rt_error: str | None = None
self.rt_tried_at: float | None = None
@property
def has_rt(self) -> bool:
return bool(self.cfg["rt_url"]) and (bool(self.cfg["rt_key"]) or not self.cfg["needs_key"])
class State:
feed: Feed | None = None
rt: dict = {}
rt_version: str | None = None
rt_at: float | None = None
rt_error: str | None = None
rt_tried_at: float | None = None
sources = {name: Source(name) for name in C.ENABLED}
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
vehicles_gz: bytes = gzip.compress(vehicles)
count = 0
@@ -39,62 +51,78 @@ class State:
S = State()
def loop(every: float, fn):
def loop(every: float, fn, *args):
def run():
while True:
t = time.time()
try:
fn()
fn(*args)
except Exception:
log.exception("Fehler in %s", fn.__name__)
log.exception("Fehler in %s %s", fn.__name__, args)
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]
"""Lädt je Quelle den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (Schweiz:
die schalten erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
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))
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
poll_realtime()
except Exception:
log.exception("Erster Echtzeit-Abruf fehlgeschlagen")
compute_positions()
for src in S.sources.values():
ready = sorted(m.parent.name for m in (C.FEEDS_DIR / src.name).glob("*/meta.json"))
if not ready:
continue
want = src.rt_version if src.rt_version in ready else ready[-1]
if src.feed and src.feed.version == want and src.feed.covers(today + dt.timedelta(days=1)):
continue
log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time()
src.feed = Feed(C.FEEDS_DIR / src.name / want)
log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t,
len(src.feed.trip_ids))
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
poll_realtime(src)
except Exception:
log.exception("[%s] Erster Echtzeit-Abruf fehlgeschlagen", src.name)
compute_positions()
def poll_realtime():
if not C.RT_KEY or not S.feed:
def poll_realtime(src: Source):
if not src.has_rt or not src.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
src.rt_version, src.rt = realtime.parse(realtime.fetch(src.cfg["rt_url"], src.cfg["rt_key"]),
src.feed.trip_index)
src.rt_at, src.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 = {}
src.rt_error = str(e)
if src.rt_at and time.time() - src.rt_at > 600:
src.rt = {}
raise
finally:
S.rt_tried_at = time.time()
src.rt_tried_at = time.time()
def positions(src: Source, t: float) -> list:
show = border.SHOW.get(src.name) if len(S.sources) > 1 else None
feats = []
for f in src.feed.vehicles(t, src.rt):
if show and not show(*f["geometry"]["coordinates"]):
continue # gehört zum Fahrplan des Nachbarlandes
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
feats.append(f)
return feats
def compute_positions():
if not S.feed:
return
now = time.time()
feats = S.feed.vehicles(now, S.rt)
future = [{f["properties"]["id"]: f["geometry"]["coordinates"] + [f["properties"]["heading"]]
for f in S.feed.vehicles(now + STEP * k, S.rt)}
for k in range(1, STEPS + 1)]
feats, future = [], [{} for _ in range(STEPS)]
for src in list(S.sources.values()):
if not src.feed:
continue
feats += positions(src, now)
for k in range(STEPS):
future[k].update({f"{src.name}/{f['properties']['id']}": f["geometry"]["coordinates"] + [f["properties"]["heading"]]
for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)})
for f in feats:
path = []
for pos in future:
@@ -114,7 +142,8 @@ 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)
for src in S.sources.values():
loop(src.cfg["rt_interval"], poll_realtime, src)
loop(3, compute_positions)
yield
@@ -130,28 +159,36 @@ def vehicles(request: Request):
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
@app.get("/api/trip/{source}/{date}/{ti}")
def trip(source: str, date: str, ti: int):
src = S.sources.get(source)
f = src.feed if src else None
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)
return f.trip_detail(date, ti, time.time(), src.rt)
@app.get("/api/status")
def status():
f = S.feed
loaded = [s for s in S.sources.values() if s.feed]
rt = [s for s in loaded if s.has_rt]
ok = [s for s in rt if not s.rt_error and s.rt_at and time.time() - s.rt_at < 180]
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,
"loaded": bool(loaded),
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
"ready": bool(f and S.computed_at and (not C.RT_KEY or (S.rt_tried_at and S.computed_at >= S.rt_tried_at))),
"ready": bool(loaded and S.computed_at and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
"vehicles": S.count,
"rt_ok": bool(ok) and len(ok) == len(loaded),
"rt_some": bool(ok),
"sources": {s.name: {
"feed_version": s.feed.version if s.feed else None,
"shapes": s.feed.meta.get("shapes") if s.feed else None,
"realtime": s.has_rt,
"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),
} for s in S.sources.values()},
}