Files
Trainpulse/trainpulse/server.py
T
Claude 2a4962c379 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
2026-10-21 21:27:54 +02:00

197 lines
7.2 KiB
Python

"""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 border, realtime
from .feed import TZ, Feed
log = logging.getLogger("trainpulse")
STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Browser Kurven nachfährt
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:
sources = {name: Source(name) for name in C.ENABLED}
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, *args):
def run():
while True:
t = time.time()
try:
fn(*args)
except Exception:
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 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()
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(src: Source):
if not src.has_rt or not src.feed:
return
try:
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
src.rt_error = str(e)
if src.rt_at and time.time() - src.rt_at > 600:
src.rt = {}
raise
finally:
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():
now = time.time()
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:
p = pos.get(f["properties"]["id"])
if p is None:
break # Fahrt endet
path.append(p)
f["path"] = path
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "step": STEP, "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)
for src in S.sources.values():
loop(src.cfg["rt_interval"], poll_realtime, src)
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", "X-Now": str(time.time())} # für den Uhrenabgleich im Browser
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/{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(), src.rt)
@app.get("/api/status")
def status():
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 {
"loaded": bool(loaded),
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
"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()},
}
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")