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
+26 -18
View File
@@ -1,29 +1,30 @@
# TrainPulse # TrainPulse
Live-Karte aller Züge in der Schweiz und in Deutschland: Position entlang der echten Strecke, Live-Karte aller Züge in der Schweiz, Deutschland, Frankreich, Belgien und den Niederlanden: Position entlang der echten Strecke,
Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte, Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2). dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
## Wie es funktioniert ## Wie es funktioniert
``` ```
opentransportdata.swiss ──GTFS (Mo+Do)──┐ Fahrpläne (GTFS) ─────────────────────────┐
gtfs.de (DELFI) ─────────GTFS (täglich)──┼▶ updater-Container (stündlich) CH opentransportdata.swiss, DE gtfs.de, ├▶ updater-Container (stündlich)
│ ├─ Filter: nur Bahn (route_type 2, 100–199) FR SNCF, NL OVapi, BE GTFS.be │ ├─ Filter: nur Bahn (route_type 2, 100–199)
OpenStreetMap CH + DE (Geofabrik) ───────┴▶ ├─ osmium: nur Schienennetz, pfaedle: Streckenverläufe OpenStreetMap (Geofabrik, 5 Länder) ──────┘ ├─ osmium: nur Schienennetz, je Land zugeschnitten
└─▶ data/feeds/<ch|de>/<version>/ ├─ pfaedle: Streckenverläufe
opentransportdata.swiss ──GTFS-RT (35 s)──┐ └─▶ data/feeds/<ch|de|fr|nl|be>/<version>/
gtfs.de ─────────────────GTFS-RT (60 s)───┴▶ web-Container (Port 8080) Echtzeit (GTFS-RT) ───────────────────────┐
├─ Position = Fahrplan + Verspätung, entlang Strecke CH (Key), DE, FR, NL └▶ web-Container (Port 8080)
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche ├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
``` ```
Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter
Verspätung berechnet und auf den Streckenverlauf gelegt. Verspätung berechnet und auf den Streckenverlauf gelegt.
Beide Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt der Die Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt jeder
Schweizer Fahrplan nur Züge innerhalb der Schweiz und der deutsche nur Züge ausserhalb Fahrplan seine Züge nur im eigenen Land; im übrigen Ausland (Österreich, Italien, …) zeigen sie alle
(Landesgrenze aus OpenStreetMap, `trainpulse/ch_border.json`). (`trainpulse/borders.json`: Schweiz aus OpenStreetMap, sonst Natural Earth).
Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr, Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr,
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
@@ -37,8 +38,8 @@ die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`
1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*): 1. **Container anlegen** (Proxmox-Oberfläche → *Create CT*):
Debian 12 oder 13 Template, unprivileged, 2 Kerne, **6 GB RAM** (pfaedle braucht beim Debian 12 oder 13 Template, unprivileged, 2 Kerne, **6 GB RAM** (pfaedle braucht beim
Aufbereiten bis ~3 GB, der Server ~1 GB), **30 GB Disk** (Kartenkacheln ~4,5 GB, beim Aufbereiten bis ~3 GB, der Server ~1 GB), **50 GB Disk** (Kartenkacheln ~9,4 GB, beim
monatlichen OSM-Update kurzzeitig ~5 GB Download für Deutschland), Netz per DHCP. monatlichen OSM-Update kurzzeitig bis ~6 GB Download für Frankreich), Netz per DHCP.
Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker Unter *Options → Features* **Nesting** und **keyctl** aktivieren – ohne das läuft Docker
im LXC nicht. im LXC nicht.
2. **Installieren** (im Container als root): 2. **Installieren** (im Container als root):
@@ -48,8 +49,8 @@ die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`
bash Trainpulse/deploy/install.sh bash Trainpulse/deploy/install.sh
``` ```
Das Skript installiert Docker, falls es fehlt, baut das Image und startet beide Container. Das Skript installiert Docker, falls es fehlt, baut das Image und startet beide Container.
Der erste Start dauert ca. 30–45 Minuten: Kartenkacheln (~4,5 GB) und OpenStreetMap für Der erste Start dauert ca. 1–1,5 Stunden: Kartenkacheln (~9,4 GB) und OpenStreetMap für alle
Deutschland (~5 GB) herunterladen, dann beide Fahrpläne aufbereiten. fünf Länder (~13 GB) herunterladen, dann alle Fahrpläne aufbereiten.
3. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren, 3. **Echtzeit freischalten:** Auf <https://api-manager.opentransportdata.swiss> registrieren,
eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in eine Applikation anlegen, das Produkt *GTFS-RT* abonnieren und den API-Key in
`Trainpulse/.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `docker compose up -d`. `Trainpulse/.env` bei `TRAINPULSE_RT_KEY=` eintragen. Dann `docker compose up -d`.
@@ -77,6 +78,10 @@ Die Hinweise erscheinen unten rechts auf der Karte.
- Fahrplan & Echtzeit Schweiz: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht) - Fahrplan & Echtzeit Schweiz: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
- Fahrplan Deutschland: [gtfs.de](https://gtfs.de), Daten von DELFI e.V. (CC BY 4.0) - Fahrplan Deutschland: [gtfs.de](https://gtfs.de), Daten von DELFI e.V. (CC BY 4.0)
- Echtzeit Deutschland: [gtfs.de](https://gtfs.de/de/realtime/) (CC BY-SA 4.0, Beta, Abdeckung je nach Region) - Echtzeit Deutschland: [gtfs.de](https://gtfs.de/de/realtime/) (CC BY-SA 4.0, Beta, Abdeckung je nach Region)
- Frankreich: SNCF über [transport.data.gouv.fr](https://transport.data.gouv.fr/datasets/horaires-sncf) (ODbL), Fahrplan und Echtzeit
- Niederlande: [OVapi / Stichting OpenGeo](https://gtfs.ovapi.nl), Fahrplan und Echtzeit
- Belgien: [GTFS.be](https://gtfs.be) (Community-Feed aus NMBS/SNCB-Daten), nur Fahrplan
- Landesgrenzen: [Natural Earth](https://www.naturalearthdata.com) (gemeinfrei) und OpenStreetMap
- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com) - Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com)
- Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL) - Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL)
- Satellit Schweiz: © swisstopo (frei, Quellenangabe) - Satellit Schweiz: © swisstopo (frei, Quellenangabe)
@@ -90,7 +95,10 @@ Die Hinweise erscheinen unten rechts auf der Karte.
- Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt - Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt
wird als Ziel der letzte Halt. wird als Ziel der letzte Halt.
- Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das - Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das
rund 9 GB Datenverkehr pro Tag. rund 9 GB Datenverkehr pro Tag; die anderen Länder zusammen ~4 GB.
- Belgien hat keine offene Echtzeit (nur nach Registrierung bei data.belgianmobility.io). Wer einen
GTFS-RT-Zugang hat, trägt URL und Key als `TRAINPULSE_BE_RT_URL` / `TRAINPULSE_BE_RT_KEY` in `.env` ein.
- Paris (RER/Transilien) ist nicht enthalten – das ist ein eigener Datensatz.
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie - Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
für ein paar Sekunden minimal ab. für ein paar Sekunden minimal ab.
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen. - Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
+3 -3
View File
@@ -9,7 +9,7 @@ cd "$(dirname "$0")/.."
command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; } command -v apt-get >/dev/null || { echo "Nur für Debian/Ubuntu (apt) gedacht" >&2; exit 1; }
mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo) mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
disk=$(df -Pm . | awk 'NR==2 {print $4}') disk=$(df -Pm . | awk 'NR==2 {print $4}')
[ "$disk" -ge 25000 ] || echo "WARNUNG: nur ${disk} MB frei – empfohlen sind ~30 GB" >&2 [ "$disk" -ge 40000 ] || echo "WARNUNG: nur ${disk} MB frei – empfohlen sind ~50 GB" >&2
[ "$mem" -ge 5500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~6 GB empfohlen" >&2 [ "$mem" -ge 5500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~6 GB empfohlen" >&2
if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then if ! command -v docker >/dev/null || ! docker compose version >/dev/null 2>&1; then
@@ -37,8 +37,8 @@ ip=$(hostname -I | awk '{print $1}')
cat <<EOF cat <<EOF
Fertig. Die Karte läuft unter http://$ip:8080 Fertig. Die Karte läuft unter http://$ip:8080
Der Updater lädt jetzt Kartenkacheln (~4,5 GB), OpenStreetMap und die Fahrpläne der Schweiz Der Updater lädt jetzt Kartenkacheln (~9,4 GB), OpenStreetMap und die Fahrpläne von CH, DE, FR,
und Deutschlands und berechnet die Streckenverläufe. Beim ersten Start dauert das ca. 30–45 Minuten. NL und BE und berechnet die Streckenverläufe. Beim ersten Start dauert das ca. 1–1,5 Stunden.
Fortschritt: docker compose logs -f updater Fortschritt: docker compose logs -f updater
Echtzeit: API-Key in $(pwd)/.env eintragen, dann docker compose up -d Echtzeit: API-Key in $(pwd)/.env eintragen, dann docker compose up -d
EOF EOF
+23 -2
View File
@@ -130,6 +130,17 @@ def test_all():
v = f.vehicles(at(8, 24), rt) # Verspätung pflanzt sich bis Gamma fort v = f.vehicles(at(8, 24), rt) # Verspätung pflanzt sich bis Gamma fort
assert v and v[0]["properties"]["delay"] == 300 assert v and v[0]["properties"]["delay"] == 300
# Frankreich/Niederlande: nur stop_id, kein stop_sequence
fm = pb.FeedMessage()
fm.header.gtfs_realtime_version = "2.0"
e = fm.entity.add(id="1")
e.trip_update.trip.trip_id, e.trip_update.trip.start_date = "T1", "20261021"
st = e.trip_update.stop_time_update.add(stop_id="B")
st.arrival.delay = st.departure.delay = 300
_, rt = realtime.parse(fm.SerializeToString(), f.trip_index)
assert rt[("T1", "20261021")][1][0][0] is None
assert f.vehicles(at(8, 14), rt)[0]["properties"]["delay"] == 300
_, rt = realtime.parse(rt_feed(cancel=True), f.trip_index) _, rt = realtime.parse(rt_feed(cancel=True), f.trip_index)
assert f.vehicles(at(8, 5), rt) == [] assert f.vehicles(at(8, 5), rt) == []
@@ -154,11 +165,21 @@ def test_all():
assert long_distance("", "ICE 75") and long_distance("IR", "IR36") and long_distance("", "ECE 20") assert long_distance("", "ICE 75") and long_distance("IR", "IR36") and long_distance("", "ECE 20")
assert not long_distance("S", "S5") and not long_distance("", "RE6") and not long_distance("", "IRE 3") assert not long_distance("S", "S5") and not long_distance("", "RE6") and not long_distance("", "IRE 3")
assert not long_distance("TER", "K1") and not long_distance("", "") assert not long_distance("TER", "K1") and not long_distance("", "")
assert long_distance("", "NJ") and long_distance("TGV", "") and long_distance("OUIGO", "")
from trainpulse.feed import LINE_PREFIX
assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct"
from trainpulse.border import SHOW, in_switzerland from trainpulse.border import country, in_switzerland, shows
assert in_switzerland(8.540, 47.378) and not in_switzerland(9.177, 47.659) # Zürich HB / Konstanz assert in_switzerland(8.540, 47.378) and not in_switzerland(9.177, 47.659) # Zürich HB / Konstanz
assert not in_switzerland(8.690, 47.697) # Büsingen (deutsche Exklave) assert not in_switzerland(8.690, 47.697) # Büsingen (deutsche Exklave)
assert SHOW["ch"](8.54, 47.378) and not SHOW["de"](8.54, 47.378) places = {(2.355, 48.881): "fr", (4.357, 50.836): "be", (4.900, 52.379): "nl", (13.369, 52.525): "de",
(8.540, 47.378): "ch", (6.13, 49.61): None, (11.40, 47.26): None} # Paris, Brüssel, Amsterdam,
for (x, y), c in places.items(): # Berlin, Zürich, Luxemburg, Innsbruck
assert country(x, y) == c, (x, y, country(x, y))
both = {"ch", "de"}
assert shows("ch", both, 8.54, 47.378) and not shows("de", both, 8.54, 47.378)
assert shows("de", both, 11.40, 47.26) and shows("ch", both, 11.40, 47.26) # Ausland: beide
assert shows("de", both, 2.355, 48.881) # Frankreich ohne eigenen Fahrplan -> DE zeigt
print("alle Tests ok") print("alle Tests ok")
+51 -11
View File
@@ -1,12 +1,22 @@
"""Landesgrenze der Schweiz (aus OpenStreetMap, auf ~400 m vereinfacht, mit den Exklaven Büsingen """Landesgrenzen, damit jeder Fahrplan seine Züge nur im eigenen Land zeigt – sonst wären
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 (z. B. ICE Basel–Frankfurt in CH- und DE-Daten).
grenzüberschreitende Züge doppelt auf der Karte."""
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 import json
from pathlib import Path from pathlib import Path
_B = json.loads((Path(__file__).with_name("ch_border.json")).read_text()) _RAW = json.loads((Path(__file__).with_name("borders.json")).read_text())
_BBOX = (min(x for x, _ in _B["outer"]), min(y for _, y in _B["outer"]), ORDER = ["ch", "de", "fr", "be", "nl"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung
max(x for x, _ in _B["outer"]), max(y for _, y in _B["outer"])) 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: def _in_ring(x: float, y: float, ring) -> bool:
@@ -17,11 +27,41 @@ def _in_ring(x: float, y: float, ring) -> bool:
return inside 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: def in_switzerland(lon: float, lat: float) -> bool:
if not (_BBOX[0] <= lon <= _BBOX[2] and _BBOX[1] <= lat <= _BBOX[3]): return _country(lon, lat) == "ch"
return False
return _in_ring(lon, lat, _B["outer"]) and not any(_in_ring(lon, lat, h) for h in _B["holes"])
# Welche Züge ein Fahrplan zeigen darf, wenn mehrere Quellen aktiv sind def shows(source: str, enabled, lon: float, lat: float) -> bool:
SHOW = {"ch": in_switzerland, "de": lambda lon, lat: not in_switzerland(lon, lat)} """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, "needs_key": False,
"rt_interval": 60, # ~6 MB pro Abruf, öfter lohnt sich nicht "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 # OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([ OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
"https://download.geofabrik.de/europe/switzerland-latest.osm.pbf", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf",
"https://download.geofabrik.de/europe/germany-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() ])).split()
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle") PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium") OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles") 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 TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)" 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 # 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", LONG_DISTANCE = {"ICE", "IC", "ICN", "ICD", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "OUIGO", "LYRIA",
"IR", "PE", "D"} "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: def long_distance(cat: str, line: str) -> bool:
@@ -76,7 +83,11 @@ class Feed:
self.version = self.meta["feed_version"] self.version = self.meta["feed_version"]
self.stop_index, self.stop_name, self.stop_lat, self.stop_lon, self.stop_platform = {}, [], [], [], [] 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"): 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_index[r[h["stop_id"]]] = len(self.stop_name)
self.stop_name.append(r[h["stop_name"]]) self.stop_name.append(r[h["stop_name"]])
self.stop_lat.append(float(r[h["stop_lat"]])) 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")} agencies = {r[h["agency_id"]]: r[h["agency_name"]] for h, r in read(path / "agency.txt")}
self.routes = {} self.routes = {}
for h, r in read(path / "routes.txt"): 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"]]] = { 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 "", "cat": r[h["route_desc"]] if "route_desc" in h else "",
"agency": agencies.get(r[h["agency_id"]], "") if "agency_id" 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_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
self.trip_headsign, self.trip_number, self.trip_shape = [], [], [] 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_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_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 "") 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 # Haltezeiten flach in Arrays; off[i]..off[i+1] sind die Halte von Fahrt i
n = len(self.trip_route) 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.shape_data = memoryview(mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)).cast("f")
self.calendar = {} 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"]]] = ( self.calendar[r[h["service_id"]]] = (
[r[h[d]] == "1" for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")], [r[h[d]] == "1" for d in ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday")],
r[h["start_date"]], r[h["end_date"]]) 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]) arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1])
if not upd: if not upd:
return arr, dep return arr, dep
seqs = self.st_seq[o0:o1] seqs, stops = self.st_seq[o0:o1], self.st_stop[o0:o1]
by_i = {} by_i, j = {}, 0
for u in upd: for u in upd:
i = bisect_right(seqs, u[0]) - 1 if u[0] is not None: # Schweiz, Deutschland: über die Halt-Nummer
if i >= 0 and seqs[i] == u[0]: i = bisect_right(seqs, u[0]) - 1
by_i[i] = u 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 cur, prev = None, -10 ** 9
for i in range(len(arr)): for i in range(len(arr)):
u = by_i.get(i) u = by_i.get(i)
if u and u[1] is not None: if u and u[2] is not None:
a = u[1] - ds if u[2] else arr[i] + u[1] a = u[2] - ds if u[3] else arr[i] + u[2]
cur = a - arr[i] cur = a - arr[i]
else: else:
a = arr[i] + (cur or 0) a = arr[i] + (cur or 0)
if u and u[3] is not None: if u and u[4] is not None:
d = u[3] - ds if u[4] else dep[i] + u[3] d = u[4] - ds if u[5] else dep[i] + u[4]
cur = d - dep[i] cur = d - dep[i]
else: else:
d = dep[i] + (cur or 0) d = dep[i] + (cur or 0)
+18 -8
View File
@@ -21,6 +21,7 @@ from pathlib import Path
import httpx import httpx
from . import border
from . import config as C from . import config as C
log = logging.getLogger("prepare") log = logging.getLogger("prepare")
@@ -189,8 +190,12 @@ def ensure_osm() -> Path:
"-o", str(part), "--overwrite"], check=True) "-o", str(part), "--overwrite"], check=True)
raw.unlink() # spart Platz raw.unlink() # spart Platz
parts.append(str(part)) parts.append(str(part))
tmp = d / "rail.tmp.osm.pbf" merged, tmp = d / "rail.merged.osm.pbf", d / "rail.tmp.osm.pbf"
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(tmp), "--overwrite"], check=True) 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) tmp.rename(rail)
urls_file.write_text(wanted) urls_file.write_text(wanted)
for p in parts: for p in parts:
@@ -200,19 +205,24 @@ def ensure_osm() -> Path:
return rail 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): if not shutil.which(C.PFAEDLE):
log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen") log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen")
return None return None
osm = ensure_osm() # Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg,
log.info("pfaedle rechnet Streckenverläufe …") # 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() 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", "-x", str(osm), str(rail)], cwd=work)
osm.unlink()
out = work / "gtfs-out" out = work / "gtfs-out"
if res.returncode != 0 or not (out / "shapes.txt").exists(): 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 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 return out
@@ -306,7 +316,7 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
if zip_path.parent == work: if zip_path.parent == work:
zip_path.unlink() zip_path.unlink()
shaped = run_pfaedle(work, work / "rail") shaped = run_pfaedle(work, work / "rail", source)
meta["shapes"] = shaped is not None meta["shapes"] = shaped is not None
if shaped: if shaped:
pack_shapes(shaped) pack_shapes(shaped)
+3 -2
View File
@@ -8,7 +8,8 @@ CANCELED = pb.TripDescriptor.CANCELED
def parse(data: bytes, known_trips: dict): 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.""" ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe."""
feed = pb.FeedMessage() feed = pb.FeedMessage()
feed.ParseFromString(data) 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] ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False]
else: else:
ev += [None, False] 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) 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 version = feed.header.feed_version if feed.header.HasField("feed_version") else None
return version, out return version, out
+1 -2
View File
@@ -110,10 +110,9 @@ def poll_realtime(src: Source):
def positions(src: Source, t: float) -> list: def positions(src: Source, t: float) -> list:
show = border.SHOW.get(src.name) if len(S.sources) > 1 else None
feats = [] feats = []
for f in src.feed.vehicles(t, src.rt): 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 continue # gehört zum Fahrplan des Nachbarlandes
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}" f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
feats.append(f) feats.append(f)
+6 -4
View File
@@ -25,8 +25,8 @@ const delayColor = ["case",
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: "map", container: "map",
center: [9.6, 49.2], // Deutschland + Schweiz center: [6.5, 49.0], // CH, DE, FR, BE, NL
zoom: 5.6, zoom: 5.2,
hash: true, hash: true,
style: { style: {
version: 8, version: 8,
@@ -51,7 +51,9 @@ const map = new maplibregl.Map({
trains: { trains: {
type: "geojson", data: EMPTY, type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, ' attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0)', + '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0), '
+ '<a href="https://transport.data.gouv.fr/datasets/horaires-sncf">SNCF</a> (ODbL), '
+ '<a href="https://gtfs.ovapi.nl">OVapi</a>, <a href="https://gtfs.be">GTFS.be</a> (NMBS/SNCB)',
}, },
}, },
layers: [ layers: [
@@ -247,7 +249,7 @@ async function refreshStatus() {
const s = await (await fetch("api/status")).json(); const s = await (await fetch("api/status")).json();
if (!s.ready) { if (!s.ready) {
loading.textContent = s.loaded ? "Züge werden geladen …" loading.textContent = s.loaded ? "Züge werden geladen …"
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 30–45 Minuten."; : "Fahrplan wird vorbereitet … beim ersten Start dauert das 1–1,5 Stunden.";
loading.hidden = false; loading.hidden = false;
return; return;
} }