Frankreich, Niederlande, Belgien als weitere Quellen (noch im Test)

- Quellen fr (SNCF), nl (OVapi), be (GTFS.be), Kacheln für alle 5 Länder
- Landesgrenzen für 5 Länder (borders.json), Gitter-Cache für die Prüfung
- Echtzeit-Abgleich auch über Haltestellen-ID (FR, NL), über den Bahnhof
  bei Gleiswechsel
- SNCF: Gattung aus der Trip-ID, Zugnummer aus dem Headsign; NL-Linien gekürzt
- calendar.txt optional (SNCF hat nur calendar_dates)
- OSM je Land zugeschnitten vor pfaedle; beim Zusammenführen je Objekt
  nur die neueste Version (time-filter)

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 07:06:47 +02:00
parent 76a8fc0806
commit d339da5da2
12 changed files with 203 additions and 68 deletions
+51 -11
View File
@@ -1,12 +1,22 @@
"""Landesgrenze der Schweiz (aus OpenStreetMap, auf ~400 m vereinfacht, mit den Exklaven Büsingen
und Campione als Löcher). Damit zeigt jeder Fahrplan seine Züge nur im eigenen Land, sonst wären
grenzüberschreitende Züge doppelt auf der Karte."""
"""Landesgrenzen, damit jeder Fahrplan seine Züge nur im eigenen Land zeigt – sonst wären
grenzüberschreitende Züge doppelt auf der Karte (z. B. ICE Basel–Frankfurt in CH- und DE-Daten).
Schweiz aus OpenStreetMap (~400 m genau, mit Büsingen und Campione als Löcher), die übrigen
Länder aus Natural Earth 1:10 Mio. (gemeinfrei), auf ~400 m vereinfacht."""
import json
from pathlib import Path
_B = json.loads((Path(__file__).with_name("ch_border.json")).read_text())
_BBOX = (min(x for x, _ in _B["outer"]), min(y for _, y in _B["outer"]),
max(x for x, _ in _B["outer"]), max(y for _, y in _B["outer"]))
_RAW = json.loads((Path(__file__).with_name("borders.json")).read_text())
ORDER = ["ch", "de", "fr", "be", "nl"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung
CELL = 0.01 # ~1 km; im Grenzbereich wird je Zelle einmal gerechnet und gemerkt
def _bbox(ring):
xs, ys = [p[0] for p in ring], [p[1] for p in ring]
return min(xs), min(ys), max(xs), max(ys)
_POLYS = {code: [(_bbox(p[0]), p) for p in _RAW[code]] for code in ORDER if code in _RAW}
def _in_ring(x: float, y: float, ring) -> bool:
@@ -17,11 +27,41 @@ def _in_ring(x: float, y: float, ring) -> bool:
return inside
def _country(x: float, y: float) -> str | None:
for code, polys in _POLYS.items():
for (x0, y0, x1, y1), (outer, *holes) in polys:
if x0 <= x <= x1 and y0 <= y <= y1 and _in_ring(x, y, outer) \
and not any(_in_ring(x, y, h) for h in holes):
return code
return None
_cache: dict = {}
def country(lon: float, lat: float) -> str | None:
"""Ländercode (ch, de, fr, be, nl) oder None für alle anderen Länder."""
key = (round(lon / CELL), round(lat / CELL))
if key not in _cache:
if len(_cache) > 500_000:
_cache.clear()
_cache[key] = _country(key[0] * CELL, key[1] * CELL)
return _cache[key]
def bbox(code: str, margin: float = 0.6) -> str:
"""Ausschnitt eines Landes (+ ~50 km Rand) als "x0,y0,x1,y1" für osmium."""
boxes = [b for b, _ in _POLYS[code]]
return ",".join(f"{v:.2f}" for v in (min(b[0] for b in boxes) - margin, min(b[1] for b in boxes) - margin,
max(b[2] for b in boxes) + margin, max(b[3] for b in boxes) + margin))
def in_switzerland(lon: float, lat: float) -> bool:
if not (_BBOX[0] <= lon <= _BBOX[2] and _BBOX[1] <= lat <= _BBOX[3]):
return False
return _in_ring(lon, lat, _B["outer"]) and not any(_in_ring(lon, lat, h) for h in _B["holes"])
return _country(lon, lat) == "ch"
# Welche Züge ein Fahrplan zeigen darf, wenn mehrere Quellen aktiv sind
SHOW = {"ch": in_switzerland, "de": lambda lon, lat: not in_switzerland(lon, lat)}
def shows(source: str, enabled, lon: float, lat: float) -> bool:
"""Zeigt diese Quelle den Zug an dieser Stelle? Im eigenen Land ja, in einem Land mit eigenem
aktivem Fahrplan nein (dort zeigt ihn dieser). Überall sonst ja."""
c = country(lon, lat)
return c is None or c == source or c not in enabled
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+26 -2
View File
@@ -21,17 +21,41 @@ SOURCES = {
"needs_key": False,
"rt_interval": 60, # ~6 MB pro Abruf, öfter lohnt sich nicht
},
"fr": { # SNCF (TGV, Intercités, TER) über transport.data.gouv.fr, ODbL
"gtfs_url": "https://eu.ftp.opendatasoft.com/sncf/plandata/Export_OpenData_SNCF_GTFS_NewTripId.zip",
"rt_url": "https://proxy.transport.data.gouv.fr/resource/sncf-gtfs-rt-trip-updates",
"rt_key": "",
"needs_key": False,
"rt_interval": 60,
},
"nl": { # OVapi / Stichting OpenGeo; Zug-Feed statt des grossen Gesamt-Feeds
"gtfs_url": "https://gtfs.ovapi.nl/nl/gtfs-nl.zip",
"rt_url": "https://gtfs.ovapi.nl/nl/trainUpdates.pb",
"rt_key": "",
"needs_key": False,
"rt_interval": 60, # OVapi: nicht öfter als 1x pro Minute
},
"be": { # SNCB/NMBS über GTFS.be (Community-Feed); Echtzeit nur mit Registrierung -> vorerst ohne
"gtfs_url": "https://data.gtfs.be/sncb/gtfs/be-sncb-gtfs.zip",
"rt_url": os.environ.get("TRAINPULSE_BE_RT_URL", ""),
"rt_key": os.environ.get("TRAINPULSE_BE_RT_KEY", ""),
"needs_key": False,
"rt_interval": 60,
},
}
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de").split(",") if s in SOURCES]
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de,fr,nl,be").split(",") if s in SOURCES]
# OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
"https://download.geofabrik.de/europe/switzerland-latest.osm.pbf",
"https://download.geofabrik.de/europe/germany-latest.osm.pbf",
"https://download.geofabrik.de/europe/france-latest.osm.pbf",
"https://download.geofabrik.de/europe/netherlands-latest.osm.pbf",
"https://download.geofabrik.de/europe/belgium-latest.osm.pbf",
])).split()
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
TILES_BBOX = "5.5,45.7,15.3,55.2" # Schweiz + Deutschland mit etwas Rand
TILES_BBOX = "-5.3,41.3,15.3,55.2" # CH, DE, FR, NL, BE mit etwas Rand
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
+45 -15
View File
@@ -59,8 +59,15 @@ def travelled(length: float, duration: float, t: float) -> float:
# Gattungen des Fernverkehrs; alles andere (RE, RB, S, R, TER, …) gilt als Nahverkehr
LONG_DISTANCE = {"ICE", "IC", "ICN", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "THA", "EST", "FLX",
"IR", "PE", "D"}
LONG_DISTANCE = {"ICE", "IC", "ICN", "ICD", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "OUIGO", "LYRIA",
"THA", "EST", "EUROSTAR", "EUROCITY", "NIGHTJET", "EUROPEAN", "FLX", "IR", "PE", "D"}
# Frankreich (SNCF): Gattung steht nur in der Trip-ID ("…_F:OUI:…"), die Linie heisst oft "INCONNU"
FR_CATEGORY = {"OUI": "TGV", "OGO": "OUIGO", "LYR": "Lyria", "ICE": "ICE", "IC": "IC", "ICN": "IC",
"TER": "TER", "CTE": "TER", "NAV": "Navette", "TRN": "TER", "CRE": "TER"}
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"
def long_distance(cat: str, line: str) -> bool:
@@ -76,7 +83,11 @@ class Feed:
self.version = self.meta["feed_version"]
self.stop_index, self.stop_name, self.stop_lat, self.stop_lon, self.stop_platform = {}, [], [], [], []
self.stop_group = array("i") # Bahnhof eines Halts (alle Gleise eines Bahnhofs -> gleiche Zahl)
groups = {}
for h, r in read(path / "stops.txt"):
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_lat.append(float(r[h["stop_lat"]]))
@@ -86,13 +97,12 @@ class Feed:
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"):
line = LINE_PREFIX.sub("", r[h["route_short_name"]] or r[h.get("route_long_name", 0)])
self.routes[r[h["route_id"]]] = {
"line": r[h["route_short_name"]] or r[h.get("route_long_name", 0)],
"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 "",
}
route = self.routes[r[h["route_id"]]]
route["fv"] = long_distance(route["cat"], route["line"])
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
self.trip_headsign, self.trip_number, self.trip_shape = [], [], []
@@ -104,6 +114,15 @@ class Feed:
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 "")
route = self.routes[r[h["route_id"]]]
m = FR_TRIP.search(r[h["trip_id"]]) if not route["cat"] else None
if m:
route["cat"] = FR_CATEGORY.get(m.group(1), m.group(1))
if self.trip_headsign[-1].isdigit() and not self.trip_number[-1]: # SNCF: Ziel = Zugnummer
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"])
# Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
n = len(self.trip_route)
@@ -136,7 +155,7 @@ class Feed:
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"):
for h, r in read(path / "calendar.txt") if (path / "calendar.txt").exists() else (): # SNCF: nur Ausnahmen
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"]])
@@ -226,22 +245,33 @@ class Feed:
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 = {}
seqs, stops = self.st_seq[o0:o1], self.st_stop[o0:o1]
by_i, j = {}, 0
for u in upd:
i = bisect_right(seqs, u[0]) - 1
if i >= 0 and seqs[i] == u[0]:
by_i[i] = u
if u[0] is not None: # Schweiz, Deutschland: über die Halt-Nummer
i = bisect_right(seqs, u[0]) - 1
if i >= 0 and seqs[i] == u[0]:
by_i[i] = u
continue
# Frankreich, Niederlande: nur Haltestellen-ID; über den Bahnhof abgleichen, damit es
# auch bei Gleiswechsel passt, und der Reihe nach (eine Fahrt kann einen Bahnhof zweimal haben)
k = self.stop_index.get(u[1])
if k is None:
continue
for i in range(j, len(stops)):
if self.stop_group[stops[i]] == self.stop_group[k]:
by_i[i], j = u, i + 1
break
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]
if u and u[2] is not None:
a = u[2] - ds if u[3] else arr[i] + u[2]
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]
if u and u[4] is not None:
d = u[4] - ds if u[5] else dep[i] + u[4]
cur = d - dep[i]
else:
d = dep[i] + (cur or 0)
+18 -8
View File
@@ -21,6 +21,7 @@ from pathlib import Path
import httpx
from . import border
from . import config as C
log = logging.getLogger("prepare")
@@ -189,8 +190,12 @@ def ensure_osm() -> Path:
"-o", str(part), "--overwrite"], check=True)
raw.unlink() # spart Platz
parts.append(str(part))
tmp = d / "rail.tmp.osm.pbf"
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(tmp), "--overwrite"], check=True)
merged, tmp = d / "rail.merged.osm.pbf", d / "rail.tmp.osm.pbf"
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(merged), "--overwrite"], check=True)
# Die Länder-Dateien sind unterschiedlich alt: Objekte an den Grenzen stecken dann in zwei
# Versionen drin, woran osmium extract scheitert. time-filter behält je Objekt die neueste.
subprocess.run([C.OSMIUM, "time-filter", str(merged), "-o", str(tmp), "--overwrite"], check=True)
merged.unlink()
tmp.rename(rail)
urls_file.write_text(wanted)
for p in parts:
@@ -200,19 +205,24 @@ def ensure_osm() -> Path:
return rail
def run_pfaedle(work: Path, rail: Path) -> Path | None:
def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
if not shutil.which(C.PFAEDLE):
log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen")
return None
osm = ensure_osm()
log.info("pfaedle rechnet Streckenverläufe …")
# Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg,
# pfaedle würde sonst das Netz aller Länder laden. Ausserhalb zeigt ohnehin der Nachbar-Fahrplan.
osm = work / "region.osm.pbf"
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(source), str(ensure_osm()), "-o", str(osm),
"--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)
osm.unlink()
out = work / "gtfs-out"
if res.returncode != 0 or not (out / "shapes.txt").exists():
log.error("pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", res.returncode)
log.error("[%s] pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", source, res.returncode)
return None
log.info("pfaedle fertig in %.0f s", time.time() - t)
log.info("[%s] pfaedle fertig in %.0f s", source, time.time() - t)
return out
@@ -306,7 +316,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
if zip_path.parent == work:
zip_path.unlink()
shaped = run_pfaedle(work, work / "rail")
shaped = run_pfaedle(work, work / "rail", source)
meta["shapes"] = shaped is not None
if shaped:
pack_shapes(shaped)
+3 -2
View File
@@ -8,7 +8,8 @@ CANCELED = pb.TripDescriptor.CANCELED
def parse(data: bytes, known_trips: dict):
"""-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, ank, ank_absolut, abf, abf_absolut), ...])})
"""-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, stop_id, ank, ank_absolut, abf, abf_absolut), ...])})
seq ist None, wenn die Quelle nur die Haltestellen-ID liefert (Frankreich, Niederlande).
ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe."""
feed = pb.FeedMessage()
feed.ParseFromString(data)
@@ -28,7 +29,7 @@ def parse(data: bytes, known_trips: dict):
ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False]
else:
ev += [None, False]
upd.append((s.stop_sequence, *ev))
upd.append((s.stop_sequence if s.HasField("stop_sequence") else None, s.stop_id, *ev))
out[(tu.trip.trip_id, tu.trip.start_date)] = (tu.trip.schedule_relationship == CANCELED, upd)
version = feed.header.feed_version if feed.header.HasField("feed_version") else None
return version, out
+1 -2
View File
@@ -110,10 +110,9 @@ def poll_realtime(src: Source):
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"]):
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']}"
feats.append(f)