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:
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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 & Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>, '
|
attribution: 'Fahrplan & 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: [
|
||||||
|
|||||||
Reference in New Issue
Block a user