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:
+51
-11
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user