Trams und U-Bahnen (CH, DE, NL): eigener Filter, ab Stadtzoom im Ausschnitt; NL-Echtzeit auch für Tram/Metro; Vorausschau ohne 7-fache Neuberechnung; deutsche Steig-Zusätze aus Haltestellennamen

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-22 10:33:45 +02:00
parent 49860c50f5
commit d11cc109f6
9 changed files with 148 additions and 56 deletions
+3 -2
View File
@@ -28,9 +28,10 @@ SOURCES = {
"needs_key": False,
"rt_interval": 60,
},
"nl": { # OVapi / Stichting OpenGeo; Zug-Feed statt des grossen Gesamt-Feeds
"nl": { # OVapi / Stichting OpenGeo; Züge und Trams/Metro stehen in zwei getrennten Feeds
"gtfs_url": "https://gtfs.ovapi.nl/nl/gtfs-nl.zip",
"rt_url": "https://gtfs.ovapi.nl/nl/trainUpdates.pb",
# mehrere Echtzeit-Adressen durch Leerzeichen getrennt
"rt_url": "https://gtfs.ovapi.nl/nl/trainUpdates.pb https://gtfs.ovapi.nl/nl/tripUpdates.pb",
"rt_key": "",
"needs_key": False,
"rt_interval": 60, # OVapi: nicht öfter als 1x pro Minute
+30 -7
View File
@@ -68,6 +68,20 @@ FR_TRIP = re.compile(r"_F:([A-Z]+):")
# Niederlande (OVapi): lange Gattungsnamen kürzen, damit die Beschriftung auf der Karte passt
LINE_ALIAS = {"Intercity": "IC", "Intercity direct": "ICD", "Sprinter": "SPR", "Nightjet": "NJ", "Eurocity": "EC"}
LINE_PREFIX = re.compile(r"^(Stoptrein|Sneltrein) (?=\S)") # "Stoptrein RS4" -> "RS4"
# Steige im deutschen Fahrplan: "S+U Rathaus Spandau Bstggl.2", "Zingster Str., Ausstieg Gl.3-5"
STOP_SUFFIX = re.compile(r",? (Ausstieg\b.*|Bstg\S*|Gl\.\s?\S+)$")
def route_mode(route_type: str) -> str | None:
"""rail, tram, subway – oder None (Bus, Schiff, Seilbahn …). GTFS-Grundtypen und "extended route types"."""
t = int(route_type)
if t == 2 or 100 <= t <= 199:
return "rail"
if t == 0 or 900 <= t <= 906:
return "tram"
if t == 1 or 400 <= t <= 404:
return "subway"
return None
def long_distance(cat: str, line: str) -> bool:
@@ -131,7 +145,7 @@ class Feed:
parent = r[h["parent_station"]] if "parent_station" in h else ""
self.stop_group.append(groups.setdefault(parent or r[h["stop_id"]], len(groups)))
self.stop_index[r[h["stop_id"]]] = len(self.stop_name)
self.stop_name.append(r[h["stop_name"]])
self.stop_name.append(STOP_SUFFIX.sub("", 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 "")
@@ -144,6 +158,7 @@ class Feed:
"line": LINE_ALIAS.get(line, "" if line == "INCONNU" else line),
"cat": r[h["route_desc"]] if "route_desc" in h else "",
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" in h else "",
"mode": route_mode(r[h["route_type"]]) or "rail",
}
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
@@ -164,7 +179,7 @@ class Feed:
self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], ""
for route in self.routes.values():
route["line"] = route["line"] or route["cat"]
route["fv"] = long_distance(route["cat"], route["line"])
route["fv"] = route["mode"] == "rail" and long_distance(route["cat"], route["line"])
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
n = len(self.trip_route)
@@ -344,7 +359,9 @@ class Feed:
# ---------- Positionen ----------
def vehicles(self, now: float, rt: dict):
def vehicles(self, now: float, rt: dict, ahead: tuple = ()):
"""Fahrzeuge zur Zeit now. Mit ahead (Sekunden) zusätzlich "_ahead": Strecken-Meter zu now+s,
solange der Zug fährt – ohne den Fahrplan dafür jedes Mal neu durchzurechnen."""
feats = []
today = dt.datetime.fromtimestamp(now, TZ).date()
for d in (today - dt.timedelta(days=1), today):
@@ -365,15 +382,20 @@ class Feed:
if not live or not (dep[live[0]] <= t <= arr[live[-1]]):
continue
pa, pd = with_min_dwell(arr, dep) # nur für die Position; Zeiten/Verspätung bleiben echt
i = min(bisect_right(pd, t), len(pd) - 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]
def at(tt): # -> (Halt i, Strecken-Meter); Zug zwischen Halt i-1 und i oder steht an i
i = min(bisect_right(pd, tt), len(pd) - 1)
if tt >= pa[i]:
return i, sd[i]
return i, sd[i - 1] + travelled(sd[i] - sd[i - 1], pa[i] - pd[i - 1], tt - pd[i - 1])
i, dist = at(t)
if t >= pa[i]: # steht am Halt i
dist = sd[i]
delay = dep[i] - self.st_dep[o + i] if t >= arr[i] else arr[i] - self.st_arr[o + i]
else:
dist = sd[i - 1] + travelled(sd[i] - sd[i - 1], pa[i] - pd[i - 1], t - pd[i - 1])
delay = arr[i] - self.st_arr[o + i]
lon, lat = self.point_at(g, dist)
# Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand
@@ -385,12 +407,13 @@ class Feed:
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "fv": route["fv"],
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "fv": route["fv"], "mode": route["mode"],
"nr": self.trip_number[ti], "from": self.stop_name[self.st_stop[o]], "to": self.trip_headsign[ti],
"delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
"heading": heading,
},
"_ti": ti, "_dist": dist, # intern, für track(); werden vor dem Versand entfernt
"_ahead": [at(t + s)[1] for s in ahead if t + s <= arr[live[-1]]],
})
return feats
+8 -7
View File
@@ -23,13 +23,14 @@ import httpx
from . import border
from . import config as C
from .feed import route_mode
log = logging.getLogger("prepare")
def is_rail(route_type: str) -> bool:
t = int(route_type)
return t == 2 or 100 <= t <= 199 # GTFS + "extended route types" (Schweiz nutzt 100–117)
# Aufbereitungs-Version: ändert sich, was aus dem Fahrplan übernommen wird, rechnet der Updater
# alle Fahrpläne einmal neu, auch ohne neuen Fahrplan (2: mit Trams und U-Bahnen)
FORMAT = 2
def timetable_year(today: dt.date) -> int:
@@ -136,7 +137,7 @@ def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
z = zipfile.ZipFile(zip_path)
h, rd = rows(z, "routes.txt")
routes = [r for r in rd if is_rail(r[h.index("route_type")])]
routes = [r for r in rd if route_mode(r[h.index("route_type")])]
write_csv(out / "routes.txt", h, routes)
route_ids = {r[h.index("route_id")] for r in routes}
@@ -226,7 +227,7 @@ def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
"--overwrite"], check=True)
log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
t = time.time()
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail", "-x", str(osm), str(rail)], cwd=work)
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail,tram,subway", "-x", str(osm), str(rail)], cwd=work)
osm.unlink()
out = work / "gtfs-out"
if res.returncode != 0 or not (out / "shapes.txt").exists():
@@ -313,7 +314,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
else:
url = gtfs_url(source)
name, version = remote_id(source, url)
if name == state.get(source) and not force:
if f"{name} #{FORMAT}" == state.get(source) and not force:
log.info("[%s] Kein neuer Fahrplan (%s)", source, name)
return
zip_path = work / "gtfs.zip"
@@ -334,7 +335,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
meta["prepared_at"] = int(time.time())
publish(source, shaped or work / "rail", meta)
shutil.rmtree(work, ignore_errors=True)
state[source] = name
state[source] = f"{name} #{FORMAT}"
log.info("[%s] Fahrplan %s bereit", source, meta["feed_version"])
+36 -18
View File
@@ -47,6 +47,9 @@ class State:
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
@@ -99,8 +102,12 @@ 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)
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)
@@ -111,9 +118,9 @@ def poll_realtime(src: Source):
src.rt_tried_at = time.time()
def positions(src: Source, t: float) -> list:
def positions(src: Source, t: float, ahead: tuple = ()) -> list:
feats = []
for f in src.feed.vehicles(t, src.rt):
for f in src.feed.vehicles(t, src.rt, ahead):
if len(S.sources) > 1 and not border.shows(src.name, S.sources, *f["geometry"]["coordinates"]):
continue # gehört zum Fahrplan des Nachbarlandes
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
@@ -127,18 +134,10 @@ def compute_positions():
for src in list(S.sources.values()):
if not src.feed:
continue
cur = positions(src, now)
# Distanz entlang der Strecke alle STEP s; daraus der Gleisverlauf, dem der Browser folgt
future = [{f["properties"]["id"]: f["_dist"] for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)}
for k in range(STEPS)] # Schlüssel Betriebstag/Fahrt: Nachtzüge laufen über Mitternacht
# 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:
key, dists = f["properties"]["id"].split("/", 1)[1], [f["_dist"]]
for pos in future:
if key not in pos:
break # Fahrt endet
dists.append(pos[key])
f["path"] = src.feed.track(f.pop("_ti"), dists, STEP)
del f["_dist"]
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
@@ -151,11 +150,13 @@ def compute_positions():
continue
seen.add((line, cx, cy))
out.append(f)
feats = out
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": feats},
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(feats), time.time()
S.count, S.computed_at = len(rail), time.time()
@asynccontextmanager
@@ -190,6 +191,22 @@ def vehicles(request: Request):
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)
@@ -211,6 +228,7 @@ def status():
"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: {