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
This commit is contained in:
Claude
2026-10-23 09:53:56 +02:00
parent 650f8dca8c
commit ef67df6e00
12 changed files with 247 additions and 57 deletions
+10
View File
@@ -1,3 +1,13 @@
# API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT"). # API-Key von https://api-manager.opentransportdata.swiss (Produkt "GTFS-RT").
# Leer lassen = Karte läuft nur nach Fahrplan, ohne Verspätungen. # Leer lassen = Karte läuft nur nach Fahrplan, ohne Verspätungen.
TRAINPULSE_RT_KEY= TRAINPULSE_RT_KEY=
# Schweden: kostenloser Key von https://developer.trafiklab.se (Projekt anlegen, "GTFS Sweden 3" und
# "GTFS Sweden 3 Realtime" hinzufügen). Ohne Key wird Schweden übersprungen.
TRAINPULSE_SE_KEY=
# Abstand der schwedischen Echtzeit-Abrufe in Sekunden. Bronze-Key: 30 000 Abrufe/Monat, bei zwei
# Regionen reichen 300 s knapp; mit Silver-Key z. B. 60.
TRAINPULSE_SE_RT_INTERVAL=300
# Nur bestimmte Quellen (Standard: alle): ch,de,fr,nl,be,es,es_cer,pl,no,dk,at,at_wl,se
#TRAINPULSE_SOURCES=
+33 -15
View File
@@ -1,7 +1,8 @@
# TrainPulse # TrainPulse
Live-Karte aller Züge in der Schweiz, Deutschland, Frankreich, Belgien und den Niederlanden, dazu Trams und U-Bahnen Live-Karte aller Züge in der Schweiz, Deutschland, Frankreich, Belgien, den Niederlanden, Österreich, Spanien,
(CH, DE, NL): Position entlang der echten Strecke, Polen, Dänemark, Norwegen und Schweden, dazu Trams und U-Bahnen (CH, DE, NL, AT/Wien, DK, NO, SE):
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).
@@ -9,13 +10,13 @@ dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der
``` ```
Fahrpläne (GTFS) ─────────────────────────┐ Fahrpläne (GTFS) ─────────────────────────┐
CH opentransportdata.swiss, DE gtfs.de, ├▶ updater-Container (stündlich) CH, DE, FR, NL, BE, AT (+Wien), ES (+ ├▶ updater-Container (stündlich)
FR SNCF, NL OVapi, BE NMBS/SNCB │ ├─ Filter: Bahn, Tram, U-Bahn (ohne Bus/Schiff) Cercanías), PL, DK, NO, SE (Key) │ ├─ Filter: Bahn, Tram, U-Bahn (ohne Bus/Schiff)
OpenStreetMap (Geofabrik, 5 Länder) ──────┘ ├─ osmium: nur Schienennetz, je Land zugeschnitten OpenStreetMap (Geofabrik, 11 Länder) ─────┘ ├─ osmium: nur Schienennetz, je Land zugeschnitten
├─ pfaedle: Streckenverläufe ├─ pfaedle: Streckenverläufe
└─▶ data/feeds/<ch|de|fr|nl|be>/<version>/ └─▶ data/feeds/<quelle>/<version>/
Echtzeit (GTFS-RT) ───────────────────────┐ Echtzeit (GTFS-RT) ───────────────────────┐
CH (Key), DE, FR, NL └▶ web-Container (Port 8080) CH (Key), DE, FR, NL, BE, ES, PL, NO └▶ web-Container (Port 8080)
├─ Position = Fahrplan + Verspätung, entlang Strecke ├─ Position = Fahrplan + Verspätung, entlang Strecke
└─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche └─ /api/vehicles, /api/trip/…, /api/status, Weboberfläche
``` ```
@@ -38,9 +39,10 @@ TrainPulse läuft als zwei Docker-Container aus demselben Image:
die Kartenkacheln). Alle Daten liegen im Ordner `data/` neben der `compose.yaml`. 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, **8 GB RAM** (Server ~4 GB, pfaedle beim
Aufbereiten bis ~2,5 GB, der Server ~2,5 GB), **50 GB Disk** (Kartenkacheln ~9,4 GB, beim Aufbereiten bis ~2,5 GB, beim Fahrplanwechsel kurz beide Versionen), **60 GB Disk**
monatlichen OSM-Update kurzzeitig bis ~6 GB Download für Frankreich), Netz per DHCP. (Kartenkacheln ~21 GB, Fahrpläne ~9 GB in je zwei Versionen, beim monatlichen OSM-Update
kurzzeitig bis ~5 GB Download), 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):
@@ -50,11 +52,14 @@ 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. 1–1,5 Stunden: Kartenkacheln (~9,4 GB) und OpenStreetMap für alle Der erste Start dauert ca. 1,5–2 Stunden: Kartenkacheln (~21 GB) und OpenStreetMap für alle
fünf Länder (~13 GB) herunterladen, dann alle Fahrpläne aufbereiten. elf Länder (~21 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`.
4. **Schweden freischalten** (optional): Auf <https://developer.trafiklab.se> ein Projekt anlegen,
*GTFS Sweden 3* und *GTFS Sweden 3 Realtime* hinzufügen und den Key in `.env` bei
`TRAINPULSE_SE_KEY=` eintragen. Ohne Key fehlt Schweden.
Danach ist die Karte unter `http://<ip>:8080` erreichbar. Für den Zugriff von aussen einfach Danach ist die Karte unter `http://<ip>:8080` erreichbar. Für den Zugriff von aussen einfach
hinter deinen bestehenden Reverse Proxy hängen. hinter deinen bestehenden Reverse Proxy hängen.
@@ -82,6 +87,12 @@ Die Hinweise erscheinen unten rechts auf der Karte.
- Frankreich: SNCF über [transport.data.gouv.fr](https://transport.data.gouv.fr/datasets/horaires-sncf) (ODbL), Fahrplan und Echtzeit - 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 - Niederlande: [OVapi / Stichting OpenGeo](https://gtfs.ovapi.nl), Fahrplan und Echtzeit
- Belgien: NMBS/SNCB [Open Data](https://www.belgiantrain.be/en/3rd-party-services/mobility-service-providers/public-data) (HAFAS-Feeds), Fahrplan und Echtzeit - Belgien: NMBS/SNCB [Open Data](https://www.belgiantrain.be/en/3rd-party-services/mobility-service-providers/public-data) (HAFAS-Feeds), Fahrplan und Echtzeit
- Spanien: [Renfe](https://data.renfe.com) (Fern-/Regionalverkehr und Cercanías), Fahrplan und Echtzeit
- Polen: [mkuran.pl](https://mkuran.pl/gtfs/) (alle Bahnbetreiber, Daten von PKP PLK), Fahrplan und Echtzeit (CC BY 4.0)
- Norwegen: [Entur](https://developer.entur.org) (NLOD), Fahrplan; Echtzeit für Vy, Flytoget, Go-Ahead, Oslo, Bergen, Trondheim
- Dänemark: [Rejseplanen](https://www.rejseplanen.dk) (alle Verkehrsmittel), nur Fahrplan
- Österreich: [ÖBB](https://data.oebb.at) und [Wiener Linien](https://www.data.gv.at) (CC BY 4.0), nur Fahrplan
- Schweden: [Trafiklab](https://www.trafiklab.se) GTFS Sweden 3 (Key nötig), Echtzeit nur Stockholm und Skåne
- Landesgrenzen: [Natural Earth](https://www.naturalearthdata.com) (gemeinfrei) und OpenStreetMap - 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)
@@ -92,15 +103,22 @@ Die Hinweise erscheinen unten rechts auf der Karte.
- Busse, Schiffe und Seilbahnen werden nicht angezeigt. Trams und U-Bahnen erscheinen erst ab - Busse, Schiffe und Seilbahnen werden nicht angezeigt. Trams und U-Bahnen erscheinen erst ab
Stadtzoom (Stufe 11) und nur im Kartenausschnitt; die Suche findet sie nur dort. Stadtzoom (Stufe 11) und nur im Kartenausschnitt; die Suche findet sie nur dort.
- Trams/U-Bahnen gibt es für CH, DE und NL. Belgien (Brüssel STIB/MIVB) und Frankreich (RATP, TCL …) - Trams/U-Bahnen gibt es für CH, DE, NL, Wien, DK, NO und SE. Belgien (Brüssel STIB/MIVB),
haben eigene Datensätze, die noch fehlen. In Berlin liefert der freie deutsche Echtzeit-Feed Frankreich (RATP, TCL …), Spanien (Metro Madrid …), Polen und Österreich ausser Wien haben eigene
Datensätze, die noch fehlen.
- Ohne offene Echtzeit (grau): Österreich, Dänemark, Schweden ausser Stockholm/Skåne. Der
österreichische ÖBB-Fahrplan erscheint nur einmal pro Jahr; Baustellenfahrpläne fehlen.
- Der polnische Echtzeit-Feed bleibt laut Betreiber ein- bis zweimal täglich über eine Stunde stehen.
- Tschechien, Italien und Grossbritannien fehlen: keine offenen Fahrplandaten (CZ, IT) bzw. kein
GTFS, nur ein eigenes Format mit Registrierung (GB). In Berlin liefert der freie deutsche Echtzeit-Feed
keine Verspätungen für U-Bahn und Tram. keine Verspätungen für U-Bahn und Tram.
- Züge des Schweizer Fahrplans im übrigen Ausland (z. B. Tirano, Domodossola, Frankreich) - Züge des Schweizer Fahrplans im übrigen Ausland (z. B. Tirano, Domodossola, Frankreich)
werden nicht angezeigt, sobald sie die Schweiz verlassen. werden nicht angezeigt, sobald sie die Schweiz verlassen.
- 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; die Niederlande (Züge + Trams/Metro, ~6 MB/min) ~8 GB, die übrigen ~4 GB. rund 9 GB Datenverkehr pro Tag; die Niederlande (Züge + Trams/Metro, ~6 MB/min) ~8 GB, Polen
(~5 MB alle 2 min) ~3,5 GB, die übrigen zusammen ~5 GB.
- Belgien: die Feed-Adressen unter sncb-opendata.hafas.de sind nicht offiziell dokumentiert und - Belgien: die Feed-Adressen unter sncb-opendata.hafas.de sind nicht offiziell dokumentiert und
laufen evtl. aus (die NMBS stellt auf data.belgianmobility.io um). Dann neue Adresse als laufen evtl. aus (die NMBS stellt auf data.belgianmobility.io um). Dann neue Adresse als
`TRAINPULSE_BE_RT_URL` (und ggf. `TRAINPULSE_BE_RT_KEY`) in `.env` eintragen. `TRAINPULSE_BE_RT_URL` (und ggf. `TRAINPULSE_BE_RT_KEY`) in `.env` eintragen.
+4 -4
View File
@@ -9,8 +9,8 @@ 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 40000 ] || echo "WARNUNG: nur ${disk} MB frei – empfohlen sind ~50 GB" >&2 [ "$disk" -ge 50000 ] || echo "WARNUNG: nur ${disk} MB frei – empfohlen sind ~60 GB" >&2
[ "$mem" -ge 5500 ] || echo "WARNUNG: nur ${mem} MB RAM – für die Fahrplan-Aufbereitung werden ~6 GB empfohlen" >&2 [ "$mem" -ge 7500 ] || echo "WARNUNG: nur ${mem} MB RAM – empfohlen sind 8 GB (Server ~4 GB + Aufbereitung)" >&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
echo "==> Docker installieren (offizielles Skript von get.docker.com)" echo "==> Docker installieren (offizielles Skript von get.docker.com)"
@@ -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 (~9,4 GB), OpenStreetMap und die Fahrpläne von CH, DE, FR, Der Updater lädt jetzt Kartenkacheln (~21 GB), OpenStreetMap und die Fahrpläne von elf Ländern
NL und BE und berechnet die Streckenverläufe. Beim ersten Start dauert das ca. 1–1,5 Stunden. und berechnet die Streckenverläufe. Beim ersten Start dauert das ca. 1,5–2 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
+44 -4
View File
@@ -208,6 +208,8 @@ def test_all():
assert [route_mode(t) for t in ("2", "106", "0", "900", "1", "401", "3", "700", "1400")] == \ assert [route_mode(t) for t in ("2", "106", "0", "900", "1", "401", "3", "700", "1400")] == \
["rail", "rail", "tram", "tram", "subway", "subway", None, None, None] # Bus, Standseilbahn: nein ["rail", "rail", "tram", "tram", "subway", "subway", None, None, None] # Bus, Standseilbahn: nein
assert long_distance("", "NJ") and long_distance("TGV", "") and long_distance("OUIGO", "") assert long_distance("", "NJ") and long_distance("TGV", "") and long_distance("OUIGO", "")
assert long_distance("", "AVE") and long_distance("", "EIP") and long_distance("", "F5") # ES, PL, Norwegen
assert not long_distance("", "F") and sbahn("", "F", "109") # Kopenhagen S-tog F
from trainpulse.feed import LINE_PREFIX from trainpulse.feed import LINE_PREFIX
assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct" assert LINE_PREFIX.sub("", "Stoptrein RS4") == "RS4" and LINE_PREFIX.sub("", "Intercity direct") == "Intercity direct"
@@ -215,12 +217,14 @@ def test_all():
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)
places = {(2.355, 48.881): "fr", (4.357, 50.836): "be", (4.900, 52.379): "nl", (13.369, 52.525): "de", 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, (8.540, 47.378): "ch", (6.13, 49.61): None, (14.51, 46.05): None, # Paris … Luxemburg, Ljubljana
for (x, y), c in places.items(): # Berlin, Zürich, Luxemburg, Innsbruck (11.40, 47.26): "at", (16.37, 48.21): "at", (-3.69, 40.41): "es", (21.01, 52.23): "pl",
(10.75, 59.91): "no", (12.565, 55.672): "dk", (18.06, 59.33): "se"} # Innsbruck, Wien, Madrid, …
for (x, y), c in places.items():
assert country(x, y) == c, (x, y, country(x, y)) assert country(x, y) == c, (x, y, country(x, y))
both = {"ch", "de"} both = {"ch", "de"}
assert shows("ch", both, 8.54, 47.378) and not shows("de", both, 8.54, 47.378) 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, 14.51, 46.05) and shows("ch", both, 14.51, 46.05) # Ausland: beide
assert shows("de", both, 2.355, 48.881) # Frankreich ohne eigenen Fahrplan -> DE zeigt assert shows("de", both, 2.355, 48.881) # Frankreich ohne eigenen Fahrplan -> DE zeigt
@@ -236,7 +240,7 @@ def test_feed_choice():
loaded = [] loaded = []
class Stub: class Stub:
def __init__(self, path): def __init__(self, path, **kw):
loaded.append(path.name) loaded.append(path.name)
self.version, self.meta, self.trip_ids = path.name, {}, [] self.version, self.meta, self.trip_ids = path.name, {}, []
@@ -253,7 +257,43 @@ def test_feed_choice():
assert loaded == ["20261021"], loaded assert loaded == ["20261021"], loaded
def test_new_countries():
"""Renfe Cercanías: aufgefüllte Felder; ÖBB: Unterordner und Gattung nur in der Zugnummer;
Renfe-Echtzeit ohne Datum."""
with tempfile.TemporaryDirectory() as d:
files = dict(FILES)
files["routes.txt"] = ("route_id ,agency_id,route_short_name,route_long_name,route_desc,route_type\n"
"R1 ,11,A10-1,,,2 \nB1,11,31,,B,700\n")
files["trips.txt"] = files["trips.txt"].replace("R1,S,T1,Ziel,812,", "R1 ,S,T1,Ziel,RJ 658,")
files["calendar.txt"] = files["calendar.txt"].replace("end_date", "end_date ") # auch nur im Kopf
files["stops.txt"] = "\n".join(l + (",level_id" if i == 0 else ",Level 0") if l else l
for i, l in enumerate(files["stops.txt"].split("\n"))) # ÖBB
zp = Path(d) / "in.zip"
with zipfile.ZipFile(zp, "w") as z:
for n, c in files.items():
z.writestr("GTFS_Fahrplan_2026/" + n, c)
flat = prepare.normalize_zip(zp, Path(d))
meta = prepare.filter_rail(flat, Path(d) / "rail", "20261020")
assert meta["trips"] == 1, meta # Route trotz "R1 " gefunden
assert "Level 0" not in (Path(d) / "rail" / "stops.txt").read_text() # levels.txt fehlt -> Verweis leer
(Path(d) / "rail" / "meta.json").write_text(json.dumps(meta))
f = Feed(Path(d) / "rail")
p = f.vehicles(at(8, 5), {})[0]["properties"]
assert (p["line"], p["nr"], p["fv"], p["sb"]) == ("RJ", "658", True, False), p
assert f.trip_detail("20261021", 0, at(8, 5), {})["line"] == "RJ"
fm = pb.FeedMessage()
fm.header.gtfs_realtime_version = "2.0"
e = fm.entity.add(id="1")
e.trip_update.trip.trip_id = "T1" # ohne start_date (Renfe)
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 f.vehicles(at(8, 14), rt)[0]["properties"]["delay"] == 300
assert prepare.normalize_zip(flat, Path(d)) == flat # schon sauber: unverändert
if __name__ == "__main__": if __name__ == "__main__":
test_all() test_all()
test_feed_choice() test_feed_choice()
test_new_countries()
print("alle Tests ok") print("alle Tests ok")
+1 -1
View File
@@ -7,7 +7,7 @@ import json
from pathlib import Path from pathlib import Path
_RAW = json.loads((Path(__file__).with_name("borders.json")).read_text()) _RAW = json.loads((Path(__file__).with_name("borders.json")).read_text())
ORDER = ["ch", "de", "fr", "be", "nl"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung ORDER = ["ch", "de", "fr", "be", "nl", "at", "dk", "es", "no", "pl", "se"] # Schweiz zuerst: genauere Grenze, gewinnt bei Überlappung
CELL = 0.01 # ~1 km; im Grenzbereich wird je Zelle einmal gerechnet und gemerkt CELL = 0.01 # ~1 km; im Grenzbereich wird je Zelle einmal gerechnet und gemerkt
File diff suppressed because one or more lines are too long
+59 -2
View File
@@ -44,8 +44,59 @@ SOURCES = {
"needs_key": False, "needs_key": False,
"rt_interval": 60, "rt_interval": 60,
}, },
# Spanien: Renfe hat Fern-/Regionalverkehr und Cercanías (S-Bahn) als getrennte Fahrpläne
"es": { # Renfe AVE, Alvia, Media Distancia …; Echtzeit ohne Datum, Trip-IDs enthalten den Tag
"gtfs_url": "https://ssl.renfe.com/gtransit/Fichero_AV_LD/google_transit.zip",
"rt_url": "https://gtfsrt.renfe.com/trip_updates_LD.pb",
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"es_cer": { # Renfe Cercanías/Rodalies (alles S-Bahn)
"country": "es", "all_sbahn": True,
"gtfs_url": "https://ssl.renfe.com/ftransit/Fichero_CER_FOMENTO/fomento_transit.zip",
"rt_url": "https://gtfsrt.renfe.com/trip_updates.pb",
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"pl": { # alle Bahnbetreiber, aufbereitet von Mikołaj Kuranowski (mkuran.pl); Echtzeit CC BY 4.0
"gtfs_url": "https://mkuran.pl/gtfs/polish_trains.zip",
"rt_url": "https://mkuran.pl/gtfs/polish_trains/updates.pb",
"rt_key": "", "needs_key": False,
"rt_interval": 120, # ~5 MB pro Abruf
},
"no": { # Entur (NLOD); Gesamt-Echtzeit wäre 15 MB, darum nur die Betreiber von Bahn, Tram, T-bane
"gtfs_url": "https://storage.googleapis.com/marduk-production/outbound/gtfs/rb_norway-aggregated-gtfs.zip",
"rt_url": " ".join(f"https://api.entur.io/realtime/v1/gtfs-rt/trip-updates?datasource={ds}" for ds in ("VYG", "FLT", "GOA", "RUT", "ATB", "SKY")),
"rt_key": "", "needs_key": False, "rt_interval": 60,
},
"dk": { # Rejseplanen (alle Verkehrsmittel); keine offene Echtzeit
"gtfs_url": "https://www.rejseplanen.info/labs/GTFS.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"at": { # ÖBB (CC BY 4.0), einmal pro Fahrplanjahr ({year}); keine offene Echtzeit
"gtfs_url": "https://static.web.oebb.at/open-data/soll-fahrplan-gtfs/GTFS_Fahrplan_{year}.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"at_wl": { # Wiener Linien (Tram, U-Bahn, Badner Bahn), CC BY 4.0
"country": "at",
"gtfs_url": "http://www.wienerlinien.at/ogd_realtime/doku/ogd/gtfs/gtfs.zip",
"rt_url": "", "rt_key": "", "needs_key": False, "rt_interval": 60,
},
"se": { # Trafiklab GTFS Sweden 3 (alle Züge); kostenloser Key nötig, auch für den Fahrplan
"gtfs_url": "https://opendata.samtrafiken.se/gtfs-sweden/sweden.zip?key={key}",
# Echtzeit nur für einige Regionen (Stockholm, Skåne); Züge landesweit noch nicht
"rt_url": " ".join("https://opendata.samtrafiken.se/gtfs-rt-sweden/{op}/TripUpdatesSweden.pb?key={key}".replace("{op}", op) for op in ("sl", "skane")),
"rt_key": os.environ.get("TRAINPULSE_SE_KEY", ""),
"needs_key": True, "static_needs_key": True,
# Bronze-Key: 30 000 Echtzeit- und 50 Fahrplan-Abrufe im Monat
"rt_interval": int(os.environ.get("TRAINPULSE_SE_RT_INTERVAL") or 300),
"check_hours": 48,
},
} }
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de,fr,nl,be").split(",") if s in SOURCES] ENABLED = [s for s in (os.environ.get("TRAINPULSE_SOURCES") or ",".join(SOURCES)).split(",") if s in SOURCES]
def country(source: str) -> str:
"""Land einer Quelle (Spanien und Österreich haben je zwei)."""
return SOURCES[source].get("country", source)
# 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([
@@ -54,10 +105,16 @@ OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
"https://download.geofabrik.de/europe/france-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/netherlands-latest.osm.pbf",
"https://download.geofabrik.de/europe/belgium-latest.osm.pbf", "https://download.geofabrik.de/europe/belgium-latest.osm.pbf",
"https://download.geofabrik.de/europe/austria-latest.osm.pbf",
"https://download.geofabrik.de/europe/denmark-latest.osm.pbf",
"https://download.geofabrik.de/europe/spain-latest.osm.pbf",
"https://download.geofabrik.de/europe/norway-latest.osm.pbf",
"https://download.geofabrik.de/europe/poland-latest.osm.pbf",
"https://download.geofabrik.de/europe/sweden-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.3,41.3,15.3,55.2" # CH, DE, FR, NL, BE mit etwas Rand TILES_BBOX = "-9.6,35.8,31.3,71.3" # von Galicien bis Nordkap, Andalusien bis Finnmark
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)"
+35 -15
View File
@@ -60,7 +60,13 @@ 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", "ICD", "EC", "ECE", "ECM", "EN", "NJ", "RJ", "RJX", "TGV", "OUIGO", "LYRIA", 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"} "THA", "EST", "EUROSTAR", "EUROCITY", "NIGHTJET", "EUROPEAN", "FLX", "IR", "PE", "D",
"AVE", "AVLO", "ALVIA", "EUROMED", # Spanien
"EIC", "EIP", "TLK", # Polen
"ICL", "WB", "SJ", "SNT", "X"} # Dänemark (IC Lyn), Westbahn, Schweden (SJ, Snälltåget, X2000)
# Zugnummer mit Gattung ("RJ 658"): bei ÖBB steht der Fernverkehr nur so, die Linie heisst "A10-1"
TRIP_CAT = re.compile(r"^([A-Z]{1,5}) ?(\d+)$")
NO_FJERNTOG = re.compile(r"^F\d+$") # Norwegen: F4, F5, F6 = Fernzüge (aber dänische S-Bahn "F" nicht)
# Frankreich (SNCF): Gattung steht nur in der Trip-ID ("…_F:OUI:…"), die Linie heisst oft "INCONNU" # 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", FR_CATEGORY = {"OUI": "TGV", "OGO": "OUIGO", "LYR": "Lyria", "ICE": "ICE", "IC": "IC", "ICN": "IC",
"TER": "TER", "CTE": "TER", "NAV": "Navette", "TRN": "TER", "CRE": "TER"} "TER": "TER", "CTE": "TER", "NAV": "Navette", "TRN": "TER", "CRE": "TER"}
@@ -87,16 +93,16 @@ def route_mode(route_type: str) -> str | None:
SBAHN = re.compile(r"^(SN? ?\d+[A-Z]?|S)$") # S1, S 5, S41, SN7 (Nacht-S-Bahn), nur "S" SBAHN = re.compile(r"^(SN? ?\d+[A-Z]?|S)$") # S1, S 5, S41, SN7 (Nacht-S-Bahn), nur "S"
def sbahn(cat: str, line: str) -> bool: def sbahn(cat: str, line: str, route_type: str = "") -> bool:
"""S-Bahn: Schweiz über die Kategorie, Deutschland/Belgien über den Liniennamen. Niederländische """S-Bahn: Schweiz über die Kategorie, Deutschland/Belgien über den Liniennamen, Dänemark (S-tog)
Sprinter und französische TER zählen als Regionalzug.""" über den Routentyp 109. Niederländische Sprinter und französische TER zählen als Regionalzug."""
return cat in ("S", "SN") or bool(SBAHN.match(line)) return cat in ("S", "SN") or route_type == "109" or bool(SBAHN.match(line))
def long_distance(cat: str, line: str) -> bool: def long_distance(cat: str, line: str) -> bool:
# Schweiz: Gattung in route_desc; gtfs.de: nur in der Linie ("ICE 75", "IC 3") # Schweiz: Gattung in route_desc; gtfs.de: nur in der Linie ("ICE 75", "IC 3")
m = re.match(r"[A-Za-z]+", cat or line or "") m = re.match(r"[A-Za-z]+", cat or line or "")
return bool(m) and m.group(0).upper() in LONG_DISTANCE return bool(m) and m.group(0).upper() in LONG_DISTANCE or bool(NO_FJERNTOG.match(line or ""))
MIN_DWELL = 40 # s; im Fahrplan steht oft Ankunft = Abfahrt, dann würde der Zug nie sichtbar halten MIN_DWELL = 40 # s; im Fahrplan steht oft Ankunft = Abfahrt, dann würde der Zug nie sichtbar halten
@@ -142,7 +148,7 @@ def simplify(p: list, tol_m: float, fixed=()) -> list:
class Feed: class Feed:
def __init__(self, path: Path, today: dt.date | None = None): def __init__(self, path: Path, today: dt.date | None = None, all_sbahn: bool = False):
self.path = path self.path = path
self.meta = json.loads((path / "meta.json").read_text()) self.meta = json.loads((path / "meta.json").read_text())
self.version = self.meta["feed_version"] self.version = self.meta["feed_version"]
@@ -163,11 +169,13 @@ class Feed:
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)]) line = LINE_PREFIX.sub("", r[h["route_short_name"]] or r[h.get("route_long_name", 0)])
if len(line) > 12: # Entur: "Ø Göteborg-Halmstad-…" -> "Ø"; Kartenbeschriftung bleibt kurz
line = line.split()[0][:12]
self.routes[r[h["route_id"]]] = { self.routes[r[h["route_id"]]] = {
"line": LINE_ALIAS.get(line, "" if line == "INCONNU" else line), "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 "",
"mode": route_mode(r[h["route_type"]]) or "rail", "mode": route_mode(r[h["route_type"]]) or "rail", "type": r[h["route_type"]],
} }
self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], [] self.trip_index, self.trip_ids, self.trip_route, self.trip_service = {}, [], [], []
@@ -188,8 +196,15 @@ class Feed:
self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], "" self.trip_number[-1], self.trip_headsign[-1] = self.trip_headsign[-1], ""
for route in self.routes.values(): for route in self.routes.values():
route["line"] = route["line"] or route["cat"] route["line"] = route["line"] or route["cat"]
route["fv"] = route["mode"] == "rail" and long_distance(route["cat"], route["line"]) # Routentyp 101/102/105 (Hochgeschwindigkeit, Fern-, Nachtzug): Schweden kennt sonst nur Nummern
route["sb"] = route["mode"] == "rail" and not route["fv"] and sbahn(route["cat"], route["line"]) route["fv"] = route["mode"] == "rail" and (long_distance(route["cat"], route["line"])
or route["type"] in ("101", "102", "105"))
route["sb"] = route["mode"] == "rail" and not route["fv"] and (all_sbahn or sbahn(route["cat"], route["line"], route["type"]))
self.trip_cat = {} # Fahrt -> Gattung aus der Zugnummer, wo die Linie keine Fernverkehrs-Gattung hat
for ti, num in enumerate(self.trip_number):
m = TRIP_CAT.match(num) if num and not num.isdigit() else None
if m and m.group(1) in LONG_DISTANCE and not self.routes[self.trip_route[ti]]["fv"]:
self.trip_cat[ti], self.trip_number[ti] = m.group(1), m.group(2)
# 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)
@@ -382,7 +397,7 @@ class Feed:
break # Liste ist nach Abfahrt sortiert break # Liste ist nach Abfahrt sortiert
if end + 7200 < t: # grosszügig, damit verspätete Züge nicht verschwinden if end + 7200 < t: # grosszügig, damit verspätete Züge nicht verschwinden
continue continue
r = rt.get((self.trip_ids[ti], dstr)) r = self.rt_for(rt, ti, dstr)
if r and r[0]: if r and r[0]:
continue # ausgefallen continue # ausgefallen
arr, dep, skip = self.times(ti, ds, r[1] if r else None) arr, dep, skip = self.times(ti, ds, r[1] if r else None)
@@ -412,12 +427,13 @@ class Feed:
(x1, y1), (x2, y2) = self.point_at(g, dist - 25), self.point_at(g, dist + 25) (x1, y1), (x2, y2) = self.point_at(g, dist - 25), self.point_at(g, dist + 25)
dx, dy = (x2 - x1) * math.cos(math.radians(lat)), y2 - y1 dx, dy = (x2 - x1) * math.cos(math.radians(lat)), y2 - y1
heading = round(math.degrees(math.atan2(dx, dy)), 1) if dx or dy else None heading = round(math.degrees(math.atan2(dx, dy)), 1) if dx or dy else None
route = self.routes[self.trip_route[ti]] route, tc = self.routes[self.trip_route[ti]], self.trip_cat.get(ti)
feats.append({ feats.append({
"type": "Feature", "type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]}, "geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": { "properties": {
"id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "fv": route["fv"], "sb": route["sb"], "mode": route["mode"], "id": f"{dstr}/{ti}", "line": tc or route["line"], "cat": route["cat"], "mode": route["mode"],
"fv": bool(tc) or route["fv"], "sb": not tc and route["sb"],
"nr": self.trip_number[ti], "from": self.stop_name[self.st_stop[o]], "to": self.trip_headsign[ti], "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]], "delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
"heading": heading, "heading": heading,
@@ -427,10 +443,14 @@ class Feed:
}) })
return feats return feats
def rt_for(self, rt: dict, ti: int, date: str):
# Renfe liefert kein Datum; dort ist die Trip-ID je Betriebstag eindeutig
return rt.get((self.trip_ids[ti], date)) or rt.get((self.trip_ids[ti], ""))
def trip_detail(self, date: str, ti: int, now: float, rt: dict): def trip_detail(self, date: str, ti: int, now: float, rt: dict):
d = dt.datetime.strptime(date, "%Y%m%d").date() d = dt.datetime.strptime(date, "%Y%m%d").date()
ds = day_start(d) ds = day_start(d)
r = rt.get((self.trip_ids[ti], date)) r = self.rt_for(rt, ti, date)
arr, dep, skip = self.times(ti, ds, r[1] if r else None) arr, dep, skip = self.times(ti, ds, r[1] if r else None)
o0, o1 = self.off[ti], self.off[ti + 1] o0, o1 = self.off[ti], self.off[ti + 1]
@@ -450,7 +470,7 @@ class Feed:
"passed": now - ds > dep[k], "skipped": k in skip, "passed": now - ds > dep[k], "skipped": k in skip,
}) })
route = self.routes[self.trip_route[ti]] route = self.routes[self.trip_route[ti]]
return {"line": route["line"], "cat": route["cat"], "agency": route["agency"], return {"line": self.trip_cat.get(ti, route["line"]), "cat": route["cat"], "agency": route["agency"],
"nr": self.trip_number[ti], "to": self.trip_headsign[ti], "nr": self.trip_number[ti], "to": self.trip_headsign[ti],
"realtime": bool(r), "canceled": bool(r and r[0]) or len(skip) == len(stops), "realtime": bool(r), "canceled": bool(r and r[0]) or len(skip) == len(stops),
"stops": stops, "shape": self.geometry(ti)[0]} "stops": stops, "shape": self.geometry(ti)[0]}
+43 -7
View File
@@ -43,7 +43,9 @@ def timetable_year(today: dt.date) -> int:
def gtfs_url(src: str) -> str: def gtfs_url(src: str) -> str:
url = C.SOURCES[src]["gtfs_url"] cfg = C.SOURCES[src]
# {year}: ÖBB (Datei je Fahrplanjahr), {key}: Schweden (Key in der Adresse)
url = cfg["gtfs_url"].replace("{year}", str(timetable_year(dt.date.today()))).replace("{key}", cfg["rt_key"])
if src == "ch" and not url: if src == "ch" and not url:
# shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge # shortcut: in der Nacht des Fahrplanwechsels fehlen bis zum nächsten Stundenlauf die Züge
url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink" url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
@@ -132,6 +134,28 @@ def filter_by_col(z: zipfile.ZipFile, name: str, col: str, keys: set, out: Path,
return n, seen return n, seen
def normalize_zip(zip_path: Path, work: Path) -> Path:
"""Eigenheiten mancher Fahrpläne glätten: Dateien in einem Unterordner (ÖBB) und mit Leerzeichen
aufgefüllte Felder ("10T0001C1 ", Renfe Cercanías). Sonst die Datei unverändert."""
z = zipfile.ZipFile(zip_path)
members = {Path(n).name: n for n in z.namelist() if n.endswith(".txt")}
padded = any(c != c.strip() for n in members.values() for c in rows(z, n)[0])
if not padded and all(members[k] == k for k in members):
return zip_path
out = work / "normalized.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=1) as o:
for base, name in members.items():
with z.open(name) as src, o.open(base, "w") as dst:
if not padded:
shutil.copyfileobj(src, dst, 1 << 20)
continue
with io.TextIOWrapper(dst, encoding="utf-8", newline="") as t: # schliessen = alles schreiben
w = csv.writer(t)
for row in csv.reader(io.TextIOWrapper(src, encoding="utf-8-sig", newline="")):
w.writerow([c.strip() for c in row])
return out
def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict: def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
out.mkdir(parents=True) out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path) z = zipfile.ZipFile(zip_path)
@@ -159,9 +183,12 @@ def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
n_st, used_stops = filter_by_col(z, "stop_times.txt", "trip_id", trip_ids, out / "stop_times.txt", "stop_id") n_st, used_stops = filter_by_col(z, "stop_times.txt", "trip_id", trip_ids, out / "stop_times.txt", "stop_id")
h, rd = rows(z, "stops.txt") h, rd = rows(z, "stops.txt")
sid, par = h.index("stop_id"), h.index("parent_station") sid = h.index("stop_id")
stops = list(rd) stops = list(rd)
parents = {r[par] for r in stops if r[sid] in used_stops} parents = {r[h.index("parent_station")] for r in stops if r[sid] in used_stops} if "parent_station" in h else set()
if "level_id" in h: # levels.txt kopieren wir nicht (ÖBB) -> Verweise leeren, sonst bricht pfaedle ab
for r in stops:
r[h.index("level_id")] = ""
write_csv(out / "stops.txt", h, (r for r in stops if r[sid] in used_stops or r[sid] in parents)) write_csv(out / "stops.txt", h, (r for r in stops if r[sid] in used_stops or r[sid] in parents))
for name in ("calendar.txt", "calendar_dates.txt"): for name in ("calendar.txt", "calendar_dates.txt"):
@@ -223,7 +250,7 @@ def run_pfaedle(work: Path, rail: Path, source: str) -> Path | None:
# Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg, # 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. # pfaedle würde sonst das Netz aller Länder laden. Ausserhalb zeigt ohnehin der Nachbar-Fahrplan.
osm = work / "region.osm.pbf" osm = work / "region.osm.pbf"
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(source), str(ensure_osm()), "-o", str(osm), subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(C.country(source)), str(ensure_osm()), "-o", str(osm),
"--overwrite"], check=True) "--overwrite"], check=True)
log.info("[%s] pfaedle rechnet Streckenverläufe …", source) log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
t = time.time() t = time.time()
@@ -308,10 +335,16 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
work.mkdir() work.mkdir()
version = None cfg, version = C.SOURCES[source], None
if zip_path: if zip_path:
name = zip_path.name name = zip_path.name
else: else:
if cfg.get("static_needs_key") and not cfg["rt_key"]:
log.info("[%s] Kein Key eingetragen – übersprungen", source)
return
if time.time() - state.get(source + "@checked", 0) < cfg.get("check_hours", 0) * 3600 and not force:
return # Anbieter mit knappem Abruf-Kontingent (Schweden) nicht stündlich fragen
state[source + "@checked"] = time.time()
url = gtfs_url(source) url = gtfs_url(source)
name, version = remote_id(source, url) name, version = remote_id(source, url)
if f"{name} #{FORMAT}" == state.get(source) and not force: if f"{name} #{FORMAT}" == state.get(source) and not force:
@@ -322,10 +355,12 @@ def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool
download(url, zip_path) download(url, zip_path)
t = time.time() t = time.time()
version = version or zip_path.stem # (nach dem Glätten hiesse die Datei sonst "normalized")
zip_path = normalize_zip(zip_path, work)
meta = filter_rail(zip_path, work / "rail", version) meta = filter_rail(zip_path, work / "rail", version)
log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t) log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
if zip_path.parent == work: for p in (work / "gtfs.zip", work / "normalized.zip"):
zip_path.unlink() p.unlink(missing_ok=True)
shaped = run_pfaedle(work, work / "rail", source) shaped = run_pfaedle(work, work / "rail", source)
meta["shapes"] = shaped is not None meta["shapes"] = shaped is not None
@@ -347,6 +382,7 @@ def main() -> None:
ap.add_argument("--force", action="store_true") ap.add_argument("--force", action="store_true")
args = ap.parse_args() args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logging.getLogger("httpx").setLevel(logging.WARNING) # würde Adressen samt Key (Schweden) loggen
if args.zip and not args.source: if args.zip and not args.source:
ap.error("--zip braucht --source") ap.error("--zip braucht --source")
+6 -3
View File
@@ -27,7 +27,8 @@ def parse(data: bytes, known_trips: dict):
for name in ("arrival", "departure"): for name in ("arrival", "departure"):
if s.HasField(name): if s.HasField(name):
x = getattr(s, name) x = getattr(s, name)
ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False] # Verspätung vor Uhrzeit: Renfe liefert beides, und die Uhrzeit ist dort oft falsch
ev += [x.delay, False] if x.HasField("delay") else [x.time, True] if x.HasField("time") else [None, False]
else: else:
ev += [None, False] ev += [None, False]
upd.append((s.stop_sequence if s.HasField("stop_sequence") else None, s.stop_id, *ev, upd.append((s.stop_sequence if s.HasField("stop_sequence") else None, s.stop_id, *ev,
@@ -38,8 +39,10 @@ def parse(data: bytes, known_trips: dict):
def fetch(url: str, key: str = "") -> bytes: def fetch(url: str, key: str = "") -> bytes:
headers = {"User-Agent": C.USER_AGENT} headers = {"User-Agent": C.USER_AGENT, "ET-Client-Name": "trainpulse"} # Entur möchte eine Kennung
if key: if "{key}" in url: # Schweden: Key in der Adresse
url = url.replace("{key}", key)
elif key:
headers["Authorization"] = f"Bearer {key}" headers["Authorization"] = f"Bearer {key}"
r = httpx.get(url, follow_redirects=True, timeout=30, headers=headers) r = httpx.get(url, follow_redirects=True, timeout=30, headers=headers)
r.raise_for_status() r.raise_for_status()
+4 -2
View File
@@ -98,7 +98,7 @@ def watch_feeds():
continue continue
log.info("[%s] Lade Fahrplan %s …", src.name, want) log.info("[%s] Lade Fahrplan %s …", src.name, want)
t = time.time() t = time.time()
src.feed = Feed(C.FEEDS_DIR / src.name / want) src.feed = Feed(C.FEEDS_DIR / src.name / want, all_sbahn=src.cfg.get("all_sbahn", False))
src.loaded_at = time.time() src.loaded_at = time.time()
log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t, log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t,
len(src.feed.trip_ids)) len(src.feed.trip_ids))
@@ -132,7 +132,8 @@ def poll_realtime(src: Source):
def positions(src: Source, t: float, ahead: tuple = ()) -> list: def positions(src: Source, t: float, ahead: tuple = ()) -> list:
feats = [] feats = []
for f in src.feed.vehicles(t, src.rt, ahead): 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"]): 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 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)
@@ -173,6 +174,7 @@ def compute_positions():
@asynccontextmanager @asynccontextmanager
async def lifespan(app): async def lifespan(app):
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 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) C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
loop(60, watch_feeds) loop(60, watch_feeds)
for src in S.sources.values(): for src in S.sources.values():
+7 -3
View File
@@ -43,8 +43,8 @@ function delayRGB(sec) { // dasselbe für die Halteliste (Badges, +x′)
const map = new maplibregl.Map({ const map = new maplibregl.Map({
container: "map", container: "map",
center: [6.5, 49.0], // CH, DE, FR, BE, NL center: [9.0, 50.5], // Mitteleuropa; Skandinavien und Spanien per Verschieben
zoom: 5.2, zoom: 4.6,
hash: true, hash: true,
style: { style: {
version: 8, version: 8,
@@ -72,7 +72,11 @@ const map = new maplibregl.Map({
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://transport.data.gouv.fr/datasets/horaires-sncf">SNCF</a> (ODbL), '
+ '<a href="https://gtfs.ovapi.nl">OVapi</a>, <a href="https://www.belgiantrain.be/en/3rd-party-services/mobility-service-providers/public-data">NMBS/SNCB</a>', + '<a href="https://gtfs.ovapi.nl">OVapi</a>, <a href="https://www.belgiantrain.be/en/3rd-party-services/mobility-service-providers/public-data">NMBS/SNCB</a>, '
+ '<a href="https://data.renfe.com">Renfe</a>, <a href="https://mkuran.pl/gtfs/">mkuran.pl</a> (PKP PLK), '
+ '<a href="https://developer.entur.org">Entur</a> (NLOD), <a href="https://www.rejseplanen.dk">Rejseplanen</a>, '
+ '<a href="https://data.oebb.at">ÖBB</a>, <a href="https://www.data.gv.at">Wiener Linien</a> (CC BY 4.0), '
+ '<a href="https://www.trafiklab.se">Trafiklab</a>',
}, },
}, },
layers: [ layers: [