Files
Trainpulse/trainpulse/server.py
T
Claude ef67df6e00 Spanien (Renfe + Cercanías), Polen, Norwegen, Dänemark, Österreich (ÖBB + Wiener Linien), Schweden (Key)
- mehrere Quellen je Land (country), Grenzen aus Natural Earth, OSM und Kachelausschnitt erweitert
- Fahrpläne glätten: Unterordner im Zip, aufgefüllte Felder, fehlende parent_station, level_id
- Gattung aus der Zugnummer (ÖBB 'RJ 658'), Fernverkehr auch über Routentyp 101/102/105
- Echtzeit: Verspätung vor Uhrzeit (Renfe), Fahrten ohne Datum, Key in der Adresse (Trafiklab)
- httpx-Logs aus (würden Keys zeigen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
2026-10-23 09:53:56 +02:00

261 lines
11 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.
"""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, read, route_mode
log = logging.getLogger("trainpulse")
STEP, STEPS = 2, 6 # Gleisverlauf für die nächsten 12 s (Abruf alle 5 s, Berechnung alle 3 s -> Reserve)
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
self.on_disk = False # ein aufbereiteter Fahrplan liegt bereit (wird gleich geladen)
self.loaded_at = 0.0
@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
# Trams/U-Bahnen: zu viele für eine Gesamtliste -> je Fahrzeug fertiges JSON, ausgeliefert nur
# für den Kartenausschnitt
urban: tuple = (0.0, []) # (Berechnungszeit, [(lon, lat, json-bytes)]) – ein Objekt, damit beides zusammenpasst
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 feed_format(path: Path, meta: dict) -> int:
"""Aufbereitungs-Format; ältere Ordner ohne Angabe: 2, wenn Trams/U-Bahnen drin sind."""
if "format" in meta:
return meta["format"]
return 2 if any(route_mode(r[h["route_type"]]) != "rail" for h, r in read(path / "routes.txt")) else 1
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()
found = {}
for src in S.sources.values(): # erst alle markieren, damit die Karte nicht mit einem Land allein startet
found[src.name] = sorted(m.parent.name for m in (C.FEEDS_DIR / src.name).glob("*/meta.json"))
src.on_disk = bool(found[src.name])
for src in S.sources.values():
if not found[src.name]:
continue
metas = {v: json.loads((C.FEEDS_DIR / src.name / v / "meta.json").read_text()) for v in found[src.name]}
# nur Versionen im neusten Aufbereitungs-Format (sonst z. B. alter Fahrplan ohne Trams, nur weil
# die Echtzeit sich noch auf ihn bezieht)
fmts = {v: feed_format(C.FEEDS_DIR / src.name / v, m) for v, m in metas.items()}
ready = [v for v in found[src.name] if fmts[v] == max(fmts.values())]
want = src.rt_version if src.rt_version in ready else ready[-1]
meta = metas[want]
if (src.feed and src.feed.version == want and src.feed.covers(today + dt.timedelta(days=1))
and src.feed.meta.get("prepared_at") == meta.get("prepared_at")): # gleiche Version neu aufbereitet?
continue
log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time()
src.feed = Feed(C.FEEDS_DIR / src.name / want, all_sbahn=src.cfg.get("all_sbahn", False))
src.loaded_at = time.time()
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:
merged, version = {}, None
for url in src.cfg["rt_url"].split(): # z. B. Niederlande: Züge und Trams in getrennten Feeds
v, part = realtime.parse(realtime.fetch(url, src.cfg["rt_key"]), src.feed.trip_index)
merged.update(part)
version = version or v
src.rt_version, src.rt = version, merged
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, ahead: tuple = ()) -> list:
feats = []
for f in src.feed.vehicles(t, src.rt, ahead):
if len(S.sources) > 1 and not border.shows(C.country(src.name), {C.country(n) for n in S.sources},
*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 = []
for src in list(S.sources.values()):
if not src.feed:
continue
# Distanz entlang der Strecke jetzt und alle STEP s; daraus der Gleisverlauf, dem der Browser folgt
cur = positions(src, now, tuple(STEP * (k + 1) for k in range(STEPS)))
for f in cur:
f["path"] = src.feed.track(f.pop("_ti"), [f.pop("_dist"), *f.pop("_ahead")], STEP)
feats += cur
# Im übrigen Ausland (Luxemburg, Österreich, …) zeigen alle Fahrpläne ihre Züge -> dort Doppelte
# (gleiche Linie, gleiche ~500-m-Zelle) entfernen, bevorzugt den mit Echtzeit behalten
seen, out = set(), []
for f in sorted(feats, key=lambda f: f["properties"]["delay"] is None):
x, y = f["geometry"]["coordinates"]
if border.country(x, y) is None:
line, cx, cy = f["properties"]["line"], round(x * 200), round(y * 200)
if any((line, cx + i, cy + j) in seen for i in (-1, 0, 1) for j in (-1, 0, 1)):
continue
seen.add((line, cx, cy))
out.append(f)
rail = [f for f in out if f["properties"]["mode"] == "rail"]
S.urban = (now, [(*f["geometry"]["coordinates"], json.dumps(f, separators=(",", ":")).encode())
for f in out if f["properties"]["mode"] != "rail"])
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": rail},
separators=(",", ":")).encode()
S.vehicles_gz = gzip.compress(S.vehicles, 5)
S.count, S.computed_at = len(rail), time.time()
@asynccontextmanager
async def lifespan(app):
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # jeder Echtzeit-Abruf, z. T. mit Key in der Adresse
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.middleware("http")
async def no_stale_frontend(request: Request, call_next):
# Nach einem Update sonst alte app.js aus dem Browser-Cache; mit no-cache fragt der Browser
# jedes Mal kurz nach (304, wenn unverändert). Kacheln dürfen normal gecacht werden.
resp = await call_next(request)
if not request.url.path.startswith(("/tiles/", "/api/")):
resp.headers["Cache-Control"] = "no-cache"
return resp
@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/urban")
def urban(request: Request, bbox: str):
"""Trams und U-Bahnen im Ausschnitt "west,süd,ost,nord"."""
try:
w, s, e, n = (float(v) for v in bbox.split(","))
except ValueError:
raise HTTPException(400, "bbox=west,süd,ost,nord")
ts, items = S.urban
feats = [j for x, y, j in items if w <= x <= e and s <= y <= n]
body = b'{"type":"FeatureCollection","ts":%.3f,"features":[%s]}' % (ts, b",".join(feats))
headers = {"Cache-Control": "no-store", "X-Now": str(time.time())}
if "gzip" in request.headers.get("accept-encoding", ""):
return Response(gzip.compress(body, 5), media_type="application/json", headers={**headers, "Content-Encoding": "gzip"})
return Response(body, 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]
pending = [s for s in S.sources.values() if s.on_disk and not s.feed] # nicht halbe Karte zeigen
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 not pending and S.computed_at
and all(S.computed_at >= s.loaded_at for s in loaded) and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
"vehicles": S.count,
"urban": len(S.urban[1]),
"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")