WIP: Deutschland als zweite Datenquelle (gtfs.de)
- mehrere Quellen (ch, de) mit je eigenem Fahrplan und Echtzeit-Feed - OSM CH+DE, mit osmium auf Schienennetz gefiltert, für pfaedle - Landesgrenze CH gegen doppelte grenzüberschreitende Züge - Import robust gegen andere Spaltenreihenfolge (gtfs.de) - Kacheln für CH+DE (Zoom 14) Noch nicht vollständig getestet (pfaedle auf Deutschland läuft). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
+1
-1
@@ -12,7 +12,7 @@ FROM golang:1.25-trixie AS pmtiles
|
|||||||
RUN go install github.com/protomaps/go-pmtiles@v1.31.2
|
RUN go install github.com/protomaps/go-pmtiles@v1.31.2
|
||||||
|
|
||||||
FROM python:3.13-slim-trixie
|
FROM python:3.13-slim-trixie
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 \
|
RUN apt-get update && apt-get install -y --no-install-recommends libzip5 zlib1g libbz2-1.0 libgomp1 osmium-tool \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/
|
COPY --from=pfaedle /usr/local/bin/pfaedle /usr/local/bin/
|
||||||
COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle
|
COPY --from=pfaedle /usr/local/etc/pfaedle /usr/local/etc/pfaedle
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
# TrainPulse – Schweiz
|
# TrainPulse
|
||||||
|
|
||||||
Live-Karte aller Züge in der Schweiz: Position entlang der echten Strecke, Linie, Zugnummer,
|
Live-Karte aller Züge in der Schweiz und in Deutschland: Position entlang der echten Strecke,
|
||||||
Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte
|
Linie, Ziel, Verspätung und Fahrtverlauf mit Gleis. Hintergrund umschaltbar zwischen Karte,
|
||||||
(OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
|
dunkler Karte (OpenStreetMap) und Satellit (swisstopo SWISSIMAGE, ausserhalb der Schweiz Sentinel-2).
|
||||||
|
|
||||||
## Wie es funktioniert
|
## Wie es funktioniert
|
||||||
|
|
||||||
```
|
```
|
||||||
opentransportdata.swiss ──GTFS (Mo+Do)──▶ updater-Container (stündlich)
|
opentransportdata.swiss ──GTFS (Mo+Do)──┐
|
||||||
├─ Filter: nur Bahn (route_type 100–199)
|
gtfs.de (DELFI) ─────────GTFS (täglich)──┼▶ updater-Container (stündlich)
|
||||||
OpenStreetMap (Geofabrik) ─────────────────▶├─ pfaedle: echte Streckenverläufe
|
│ ├─ Filter: nur Bahn (route_type 2, 100–199)
|
||||||
└─▶ data/feeds/<version>/
|
OpenStreetMap CH + DE (Geofabrik) ───────┴▶ ├─ osmium: nur Schienennetz, pfaedle: Streckenverläufe
|
||||||
opentransportdata.swiss ──GTFS-RT (35 s)──▶ web-Container (Port 8080)
|
└─▶ data/feeds/<ch|de>/<version>/
|
||||||
|
opentransportdata.swiss ──GTFS-RT (35 s)──┐
|
||||||
|
gtfs.de ─────────────────GTFS-RT (60 s)───┴▶ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Es gibt in der Schweiz **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan
|
Es gibt **keine offenen GPS-Positionen** der Züge. Die Position wird aus Fahrplan und gemeldeter
|
||||||
und gemeldeter Verspätung berechnet und auf den Streckenverlauf gelegt.
|
Verspätung berechnet und auf den Streckenverlauf gelegt.
|
||||||
|
|
||||||
|
Beide Fahrpläne enthalten grenzüberschreitende Züge. Damit sie nicht doppelt erscheinen, zeigt der
|
||||||
|
Schweizer Fahrplan nur Züge innerhalb der Schweiz und der deutsche nur Züge ausserhalb
|
||||||
|
(Landesgrenze aus OpenStreetMap, `trainpulse/ch_border.json`).
|
||||||
|
|
||||||
Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr,
|
Wechsel des Fahrplans: Neue Fahrpläne erscheinen Montag und Donnerstag zwischen 9 und 10 Uhr,
|
||||||
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
|
die Echtzeitdaten stellen erst um 15 Uhr um. Der Server lädt deshalb immer den Fahrplan, auf den
|
||||||
@@ -57,7 +63,7 @@ hinter deinen bestehenden Reverse Proxy hängen.
|
|||||||
| Status | `curl localhost:8080/api/status` |
|
| Status | `curl localhost:8080/api/status` |
|
||||||
| Logs Webserver | `docker compose logs -f web` |
|
| Logs Webserver | `docker compose logs -f web` |
|
||||||
| Logs Fahrplan-Update | `docker compose logs -f updater` |
|
| Logs Fahrplan-Update | `docker compose logs -f updater` |
|
||||||
| Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --force` |
|
| Fahrplan sofort neu aufbereiten | `docker compose exec updater python -m trainpulse.prepare --source de --force` (oder `ch`) |
|
||||||
| Selbsttest | `docker compose exec web python tests/test_core.py` |
|
| Selbsttest | `docker compose exec web python tests/test_core.py` |
|
||||||
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
|
| Stoppen / Starten | `docker compose down` / `docker compose up -d` |
|
||||||
|
|
||||||
@@ -65,7 +71,9 @@ hinter deinen bestehenden Reverse Proxy hängen.
|
|||||||
|
|
||||||
Die Hinweise erscheinen unten rechts auf der Karte.
|
Die Hinweise erscheinen unten rechts auf der Karte.
|
||||||
|
|
||||||
- Fahrplan & Echtzeit: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
|
- Fahrplan & Echtzeit Schweiz: [opentransportdata.swiss](https://opentransportdata.swiss) (Quellenangabe Pflicht)
|
||||||
|
- Fahrplan Deutschland: [gtfs.de](https://gtfs.de), Daten von DELFI e.V. (CC BY 4.0)
|
||||||
|
- Echtzeit Deutschland: [gtfs.de](https://gtfs.de/de/realtime/) (CC BY-SA 4.0, Beta, Abdeckung je nach Region)
|
||||||
- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com)
|
- Karte: © OpenStreetMap-Mitwirkende (ODbL), Kacheln von [Protomaps](https://protomaps.com)
|
||||||
- Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL)
|
- Streckenverläufe: berechnet mit [pfaedle](https://github.com/ad-freiburg/pfaedle) aus OpenStreetMap-Daten (ODbL)
|
||||||
- Satellit Schweiz: © swisstopo (frei, Quellenangabe)
|
- Satellit Schweiz: © swisstopo (frei, Quellenangabe)
|
||||||
@@ -73,9 +81,13 @@ Die Hinweise erscheinen unten rechts auf der Karte.
|
|||||||
|
|
||||||
## Bekannte Grenzen
|
## Bekannte Grenzen
|
||||||
|
|
||||||
- Nur Bahn. Busse, Trams und Schiffe sind im Fahrplan, werden aber noch herausgefiltert.
|
- Nur Bahn. Busse, Trams und U-Bahnen sind im Fahrplan, werden aber noch herausgefiltert.
|
||||||
- Der Schweizer Fahrplan enthält auch einige ausländische Züge (z. B. TGV, TER); deren Strecke
|
- Züge des Schweizer Fahrplans im übrigen Ausland (z. B. Tirano, Domodossola, Frankreich)
|
||||||
ausserhalb der Schweiz ist nur Luftlinie, weil der OSM-Ausschnitt nur die Schweiz abdeckt.
|
werden nicht angezeigt, sobald sie die Schweiz verlassen.
|
||||||
|
- Der kostenlose deutsche Fahrplan enthält keine Zugnummern und kein Fahrtziel; angezeigt
|
||||||
|
wird als Ziel der letzte Halt.
|
||||||
|
- Der deutsche Echtzeit-Feed ist ~6 MB gross (komprimiert). Bei einem Abruf pro Minute sind das
|
||||||
|
rund 9 GB Datenverkehr pro Tag.
|
||||||
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
|
- Zwischen zwei Abfragen fahren die Züge auf gerader Linie; in engen Kurven schneiden sie
|
||||||
für ein paar Sekunden minimal ab.
|
für ein paar Sekunden minimal ab.
|
||||||
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
|
- Zusätzliche Fahrten, die nur in den Echtzeitdaten vorkommen (Extrazüge), fehlen.
|
||||||
|
|||||||
+32
-5
@@ -1,5 +1,7 @@
|
|||||||
"""Selbsttest mit einem Mini-Fahrplan: python -m pytest tests (oder: python tests/test_core.py)"""
|
"""Selbsttest mit einem Mini-Fahrplan: python -m pytest tests (oder: python tests/test_core.py)"""
|
||||||
|
import csv
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import io
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -36,14 +38,29 @@ SHAPE = "shape_id,shape_pt_lat,shape_pt_lon,shape_pt_sequence,shape_dist_travele
|
|||||||
DAY = dt.date(2026, 10, 21)
|
DAY = dt.date(2026, 10, 21)
|
||||||
|
|
||||||
|
|
||||||
def make_feed(tmp: Path, shapes=True) -> Feed:
|
def german_style(files: dict) -> dict:
|
||||||
|
"""Wie gtfs.de: Spalten in anderer Reihenfolge, kein Fahrtziel/keine Zugnummer, Version 'latest-free'."""
|
||||||
|
out = {}
|
||||||
|
for n, c in files.items():
|
||||||
|
rows = list(csv.reader(io.StringIO(c)))
|
||||||
|
if n == "trips.txt":
|
||||||
|
rows = [r[:3] for r in rows] # route_id, service_id, trip_id
|
||||||
|
if n == "feed_info.txt":
|
||||||
|
rows[1][1] = "latest-free"
|
||||||
|
rows = [r[::-1] for r in rows] # Spalten rückwärts
|
||||||
|
out[n] = "".join(",".join(r) + "\n" for r in rows)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def make_feed(tmp: Path, shapes=True, files=FILES) -> Feed:
|
||||||
tmp.mkdir(parents=True, exist_ok=True)
|
tmp.mkdir(parents=True, exist_ok=True)
|
||||||
zp = tmp / "in.zip"
|
zp = tmp / "in.zip"
|
||||||
with zipfile.ZipFile(zp, "w") as z:
|
with zipfile.ZipFile(zp, "w") as z:
|
||||||
for n, c in FILES.items():
|
for n, c in files.items():
|
||||||
z.writestr(n, "" + c) # wie die echten Schweizer Dateien: mit BOM
|
z.writestr(n, "\ufeff" + c) # wie die echten Schweizer Dateien: mit BOM
|
||||||
meta = prepare.filter_rail(zp, tmp / "rail")
|
meta = prepare.filter_rail(zp, tmp / "rail", "20261020")
|
||||||
assert meta["feed_version"] == "20261017" and meta["trips"] == 1 and meta["stop_times"] == 3, meta
|
assert meta["feed_version"] == ("20261017" if files is FILES else "20261020"), meta
|
||||||
|
assert meta["trips"] == 1 and meta["stop_times"] == 3, meta
|
||||||
if shapes:
|
if shapes:
|
||||||
(tmp / "rail" / "shapes.txt").write_text(SHAPE)
|
(tmp / "rail" / "shapes.txt").write_text(SHAPE)
|
||||||
prepare.pack_shapes(tmp / "rail")
|
prepare.pack_shapes(tmp / "rail")
|
||||||
@@ -127,6 +144,16 @@ def test_all():
|
|||||||
|
|
||||||
g = make_feed(Path(d) / "noshape", shapes=False) # ohne shapes: Luftlinie
|
g = make_feed(Path(d) / "noshape", shapes=False) # ohne shapes: Luftlinie
|
||||||
assert g.vehicles(at(8, 5), {})[0]["geometry"]["coordinates"] == [8.05, 47.0]
|
assert g.vehicles(at(8, 5), {})[0]["geometry"]["coordinates"] == [8.05, 47.0]
|
||||||
|
|
||||||
|
de = make_feed(Path(d) / "de", shapes=False, files=german_style(FILES)) # Format von gtfs.de
|
||||||
|
v = de.vehicles(at(8, 5), {})
|
||||||
|
assert len(v) == 1 and v[0]["properties"]["to"] == "Gamma" and v[0]["properties"]["nr"] == ""
|
||||||
|
assert de.vehicles(dt.datetime(2026, 10, 25, 8, 5, tzinfo=TZ).timestamp(), {}) == [] # calendar_dates
|
||||||
|
|
||||||
|
from trainpulse.border import SHOW, in_switzerland
|
||||||
|
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 SHOW["ch"](8.54, 47.378) and not SHOW["de"](8.54, 47.378)
|
||||||
print("alle Tests ok")
|
print("alle Tests ok")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Landesgrenze der Schweiz (aus OpenStreetMap, auf ~400 m vereinfacht, mit den Exklaven Büsingen
|
||||||
|
und Campione als Löcher). Damit zeigt jeder Fahrplan seine Züge nur im eigenen Land, sonst wären
|
||||||
|
grenzüberschreitende Züge doppelt auf der Karte."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_B = json.loads((Path(__file__).with_name("ch_border.json")).read_text())
|
||||||
|
_BBOX = (min(x for x, _ in _B["outer"]), min(y for _, y in _B["outer"]),
|
||||||
|
max(x for x, _ in _B["outer"]), max(y for _, y in _B["outer"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _in_ring(x: float, y: float, ring) -> bool:
|
||||||
|
inside = False
|
||||||
|
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
|
||||||
|
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
|
||||||
|
inside = not inside
|
||||||
|
return inside
|
||||||
|
|
||||||
|
|
||||||
|
def in_switzerland(lon: float, lat: float) -> bool:
|
||||||
|
if not (_BBOX[0] <= lon <= _BBOX[2] and _BBOX[1] <= lat <= _BBOX[3]):
|
||||||
|
return False
|
||||||
|
return _in_ring(lon, lat, _B["outer"]) and not any(_in_ring(lon, lat, h) for h in _B["holes"])
|
||||||
|
|
||||||
|
|
||||||
|
# Welche Züge ein Fahrplan zeigen darf, wenn mehrere Quellen aktiv sind
|
||||||
|
SHOW = {"ch": in_switzerland, "de": lambda lon, lat: not in_switzerland(lon, lat)}
|
||||||
File diff suppressed because one or more lines are too long
+27
-8
@@ -2,17 +2,36 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DATA_DIR = Path(os.environ.get("TRAINPULSE_DATA", "/var/lib/trainpulse"))
|
DATA_DIR = Path(os.environ.get("TRAINPULSE_DATA", "/var/lib/trainpulse"))
|
||||||
FEEDS_DIR = DATA_DIR / "feeds" # fertig aufbereitete Fahrpläne, ein Ordner pro feed_version
|
FEEDS_DIR = DATA_DIR / "feeds" # fertig aufbereitete Fahrpläne: feeds/<quelle>/<version>/
|
||||||
TZ = "Europe/Zurich"
|
TZ = "Europe/Zurich"
|
||||||
|
|
||||||
# Schweiz: offizieller GTFS-Fahrplan + GTFS-RT (Trip Updates) von opentransportdata.swiss
|
# Datenquellen. Jede hat einen GTFS-Fahrplan und einen passenden GTFS-RT-Feed (Trip Updates).
|
||||||
GTFS_URL = os.environ.get("TRAINPULSE_GTFS_URL", "") # leer = automatisch das aktuelle Fahrplanjahr
|
SOURCES = {
|
||||||
RT_URL = os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt")
|
"ch": { # opentransportdata.swiss; GTFS-URL leer = automatisch das aktuelle Fahrplanjahr
|
||||||
RT_KEY = os.environ.get("TRAINPULSE_RT_KEY", "")
|
"gtfs_url": os.environ.get("TRAINPULSE_GTFS_URL", ""),
|
||||||
RT_INTERVAL = 35 # Sekunden; Limit sind 2 Abfragen/Minute (Sliding Window)
|
"rt_url": os.environ.get("TRAINPULSE_RT_URL", "https://api.opentransportdata.swiss/la/gtfs-rt"),
|
||||||
|
"rt_key": os.environ.get("TRAINPULSE_RT_KEY", ""),
|
||||||
|
"needs_key": True,
|
||||||
|
"rt_interval": 35, # Limit sind 2 Abfragen/Minute (Sliding Window)
|
||||||
|
},
|
||||||
|
"de": { # gtfs.de (Daten von DELFI e.V.), Fahrplan CC BY 4.0, Echtzeit CC BY-SA 4.0
|
||||||
|
"gtfs_url": os.environ.get("TRAINPULSE_DE_GTFS_URL", "https://download.gtfs.de/germany/free/latest.zip"),
|
||||||
|
"rt_url": os.environ.get("TRAINPULSE_DE_RT_URL", "https://realtime.gtfs.de/realtime-free.pb"),
|
||||||
|
"rt_key": "",
|
||||||
|
"needs_key": False,
|
||||||
|
"rt_interval": 60, # ~6 MB pro Abruf, öfter lohnt sich nicht
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ENABLED = [s for s in os.environ.get("TRAINPULSE_SOURCES", "ch,de").split(",") if s in SOURCES]
|
||||||
|
|
||||||
OSM_URL = os.environ.get("TRAINPULSE_OSM_URL", "https://download.geofabrik.de/europe/switzerland-latest.osm.pbf")
|
# OpenStreetMap: daraus werden das Schienennetz (für pfaedle) gefiltert
|
||||||
|
OSM_URLS = os.environ.get("TRAINPULSE_OSM_URLS", " ".join([
|
||||||
|
"https://download.geofabrik.de/europe/switzerland-latest.osm.pbf",
|
||||||
|
"https://download.geofabrik.de/europe/germany-latest.osm.pbf",
|
||||||
|
])).split()
|
||||||
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
|
PFAEDLE = os.environ.get("TRAINPULSE_PFAEDLE", "pfaedle")
|
||||||
|
OSMIUM = os.environ.get("TRAINPULSE_OSMIUM", "osmium")
|
||||||
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
|
PMTILES = os.environ.get("TRAINPULSE_PMTILES", "pmtiles")
|
||||||
TILES_BBOX = "5.8,45.7,10.6,47.9" # Schweiz mit etwas Rand
|
TILES_BBOX = "5.5,45.7,15.3,55.2" # Schweiz + Deutschland mit etwas Rand
|
||||||
|
TILES_MAXZOOM = 14 # die Karte vergrössert Vektorkacheln darüber hinaus selbst; spart viel Platz
|
||||||
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
|
USER_AGENT = "trainpulse/0.1 (+https://scarriffle.com)"
|
||||||
|
|||||||
+8
-5
@@ -111,6 +111,9 @@ class Feed:
|
|||||||
self.st_dep.append(d)
|
self.st_dep.append(d)
|
||||||
self.st_dist.append(m)
|
self.st_dist.append(m)
|
||||||
self.off[n] = len(self.st_stop)
|
self.off[n] = len(self.st_stop)
|
||||||
|
for ti in range(n): # gtfs.de liefert kein Fahrtziel: dann der letzte Halt
|
||||||
|
if not self.trip_headsign[ti] and self.off[ti + 1] > self.off[ti]:
|
||||||
|
self.trip_headsign[ti] = self.stop_name[self.st_stop[self.off[ti + 1] - 1]]
|
||||||
|
|
||||||
self.shape_index, self.shape_data = {}, None
|
self.shape_index, self.shape_data = {}, None
|
||||||
if (path / "shapes.bin").exists() and (path / "shapes.bin").stat().st_size:
|
if (path / "shapes.bin").exists() and (path / "shapes.bin").stat().st_size:
|
||||||
@@ -131,12 +134,12 @@ class Feed:
|
|||||||
cd = path / "calendar_dates.txt"
|
cd = path / "calendar_dates.txt"
|
||||||
if cd.exists():
|
if cd.exists():
|
||||||
with open(cd, encoding="utf-8-sig") as f:
|
with open(cd, encoding="utf-8-sig") as f:
|
||||||
assert next(f).strip().replace('"', "").startswith("service_id,date,exception_type")
|
h = next(f).strip().replace('"', "").split(",") # Spaltenreihenfolge ist je Quelle anders
|
||||||
|
si, di, ti_ = h.index("service_id"), h.index("date"), h.index("exception_type")
|
||||||
for line in f:
|
for line in f:
|
||||||
sid, date, typ = line.rstrip("\r\n").rsplit(",", 2)
|
v = line.rstrip("\r\n").replace('"', "").split(",") # kurze Datei ohne Kommas in Werten
|
||||||
date = date.strip('"')
|
if v[di] in window:
|
||||||
if date in window:
|
self.exceptions.setdefault(v[di], {})[v[si]] = int(v[ti_])
|
||||||
self.exceptions.setdefault(date, {})[sid.strip('"')] = int(typ.strip('"'))
|
|
||||||
|
|
||||||
self._day_cache = {}
|
self._day_cache = {}
|
||||||
self._geom_cache = {}
|
self._geom_cache = {}
|
||||||
|
|||||||
+134
-62
@@ -1,9 +1,10 @@
|
|||||||
"""Fahrplan-Update: holt den Schweizer GTFS-Fahrplan, filtert auf Bahn und rechnet mit pfaedle
|
"""Fahrplan-Update: holt für jede Quelle (Schweiz, Deutschland) den GTFS-Fahrplan, filtert auf Bahn
|
||||||
die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich per systemd-Timer und
|
und rechnet mit pfaedle die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich
|
||||||
macht nichts, solange opentransportdata.swiss keinen neuen Fahrplan veröffentlicht hat.
|
und macht für eine Quelle nichts, solange dort kein neuer Fahrplan veröffentlicht wurde.
|
||||||
|
|
||||||
python -m trainpulse.prepare # normaler Lauf
|
python -m trainpulse.prepare # alle Quellen
|
||||||
python -m trainpulse.prepare --zip X # lokale Datei statt Download (zum Testen)
|
python -m trainpulse.prepare --source de --force # eine Quelle neu aufbereiten
|
||||||
|
python -m trainpulse.prepare --source ch --zip X # lokale Datei statt Download (zum Testen)
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
@@ -39,10 +40,12 @@ def timetable_year(today: dt.date) -> int:
|
|||||||
return today.year
|
return today.year
|
||||||
|
|
||||||
|
|
||||||
def gtfs_url() -> str:
|
def gtfs_url(src: str) -> str:
|
||||||
|
url = C.SOURCES[src]["gtfs_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
|
||||||
return C.GTFS_URL or (
|
url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
|
||||||
f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink")
|
return url
|
||||||
|
|
||||||
|
|
||||||
def download(url: str, dest: Path) -> None:
|
def download(url: str, dest: Path) -> None:
|
||||||
@@ -56,6 +59,19 @@ def download(url: str, dest: Path) -> None:
|
|||||||
tmp.rename(dest)
|
tmp.rename(dest)
|
||||||
|
|
||||||
|
|
||||||
|
def remote_id(src: str, url: str) -> tuple[str, str | None]:
|
||||||
|
"""Kennung der aktuell veröffentlichten Datei, ohne sie herunterzuladen: (Name, Datum JJJJMMTT oder None)."""
|
||||||
|
if src == "ch":
|
||||||
|
return latest_gtfs_name(url), None
|
||||||
|
r = httpx.head(url, follow_redirects=True, timeout=60, headers={"User-Agent": C.USER_AGENT})
|
||||||
|
r.raise_for_status()
|
||||||
|
lm = r.headers.get("last-modified")
|
||||||
|
if not lm:
|
||||||
|
raise RuntimeError(f"{url}: kein Last-Modified-Header")
|
||||||
|
date = dt.datetime.strptime(lm, "%a, %d %b %Y %H:%M:%S GMT").strftime("%Y%m%d")
|
||||||
|
return f"{src}-{lm}", date
|
||||||
|
|
||||||
|
|
||||||
def latest_gtfs_name(url: str) -> str:
|
def latest_gtfs_name(url: str) -> str:
|
||||||
# Der Permalink leitet auf .../gtfs_fp2026_YYYYMMDD.zip weiter. HEAD geht beim Speicher nicht,
|
# Der Permalink leitet auf .../gtfs_fp2026_YYYYMMDD.zip weiter. HEAD geht beim Speicher nicht,
|
||||||
# darum nur die erste Weiterleitung lesen – so sehen wir ohne 300-MB-Download, ob es Neues gibt.
|
# darum nur die erste Weiterleitung lesen – so sehen wir ohne 300-MB-Download, ob es Neues gibt.
|
||||||
@@ -83,26 +99,33 @@ def write_csv(path: Path, header, data) -> int:
|
|||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def filter_by_first_col(z: zipfile.ZipFile, name: str, keys: set, out: Path, collect: str | None = None):
|
def filter_by_col(z: zipfile.ZipFile, name: str, col: str, keys: set, out: Path, collect: str | None = None):
|
||||||
"""Schnellfilter für die Riesendateien (stop_times.txt hat ~3.7 GB): vergleicht nur das erste Feld
|
"""Filter für die Riesendateien (stop_times.txt hat mehrere GB): behält Zeilen, deren Spalte `col`
|
||||||
als Rohtext, schreibt Treffer unverändert weg. Gibt (Anzahl, Werte der Spalte `collect`) zurück."""
|
in `keys` ist, und schreibt sie unverändert weg. Steht `col` vorne, wird nur das erste Feld als
|
||||||
|
Rohtext verglichen (schnell). Gibt (Anzahl, Werte der Spalte `collect`) zurück."""
|
||||||
n, seen = 0, set()
|
n, seen = 0, set()
|
||||||
with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f:
|
with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f:
|
||||||
lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
|
lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
|
||||||
header = next(csv.reader([lines.readline()]))
|
header = next(csv.reader([lines.readline()]))
|
||||||
assert header[0] in ("trip_id", "service_id"), header
|
|
||||||
f.write(",".join(header) + "\n")
|
f.write(",".join(header) + "\n")
|
||||||
ci = header.index(collect) if collect else None
|
ki, ci = header.index(col), header.index(collect) if collect else None
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.split(",", 1)[0].strip('"') in keys:
|
if ki == 0:
|
||||||
|
if line.split(",", 1)[0].strip('"') not in keys:
|
||||||
|
continue
|
||||||
|
row = next(csv.reader([line])) if ci is not None else None
|
||||||
|
else:
|
||||||
|
row = next(csv.reader([line]))
|
||||||
|
if row[ki] not in keys:
|
||||||
|
continue
|
||||||
f.write(line)
|
f.write(line)
|
||||||
n += 1
|
n += 1
|
||||||
if ci is not None:
|
if ci is not None:
|
||||||
seen.add(next(csv.reader([line]))[ci])
|
seen.add(row[ci])
|
||||||
return n, seen
|
return n, seen
|
||||||
|
|
||||||
|
|
||||||
def filter_rail(zip_path: Path, out: Path) -> 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)
|
||||||
|
|
||||||
@@ -119,20 +142,21 @@ def filter_rail(zip_path: Path, out: Path) -> dict:
|
|||||||
|
|
||||||
if "frequencies.txt" in z.namelist():
|
if "frequencies.txt" in z.namelist():
|
||||||
fh, frd = rows(z, "frequencies.txt")
|
fh, frd = rows(z, "frequencies.txt")
|
||||||
n = sum(1 for r in frd if r[0] in trip_ids)
|
n = sum(1 for r in frd if r[fh.index("trip_id")] in trip_ids)
|
||||||
if n:
|
if n:
|
||||||
log.warning("%d Bahnfahrten nur über frequencies.txt – werden ignoriert", n)
|
log.warning("%d Bahnfahrten nur über frequencies.txt – werden ignoriert", n)
|
||||||
|
|
||||||
n_st, used_stops = filter_by_first_col(z, "stop_times.txt", 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")
|
||||||
stops = list(rd)
|
stops = list(rd)
|
||||||
parents = {r[h.index("parent_station")] for r in stops if r[0] in used_stops}
|
parents = {r[par] for r in stops if r[sid] in used_stops}
|
||||||
write_csv(out / "stops.txt", h, (r for r in stops if r[0] in used_stops or r[0] 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"):
|
||||||
if name in z.namelist():
|
if name in z.namelist():
|
||||||
filter_by_first_col(z, name, service_ids, out / name)
|
filter_by_col(z, name, "service_id", service_ids, out / name)
|
||||||
for name in ("agency.txt", "feed_info.txt"):
|
for name in ("agency.txt", "feed_info.txt"):
|
||||||
if name in z.namelist():
|
if name in z.namelist():
|
||||||
h, rd = rows(z, name)
|
h, rd = rows(z, name)
|
||||||
@@ -140,17 +164,40 @@ def filter_rail(zip_path: Path, out: Path) -> dict:
|
|||||||
|
|
||||||
h, rd = rows(z, "feed_info.txt") if "feed_info.txt" in z.namelist() else ([], iter([]))
|
h, rd = rows(z, "feed_info.txt") if "feed_info.txt" in z.namelist() else ([], iter([]))
|
||||||
info = dict(zip(h, next(rd, [])))
|
info = dict(zip(h, next(rd, [])))
|
||||||
return {"feed_version": info.get("feed_version") or zip_path.stem,
|
fv = info.get("feed_version", "")
|
||||||
|
return {"feed_version": fv if fv.isdigit() else (version or zip_path.stem), # gtfs.de: "latest-free"
|
||||||
"routes": len(routes), "trips": len(trips), "stop_times": n_st}
|
"routes": len(routes), "trips": len(trips), "stop_times": n_st}
|
||||||
|
|
||||||
|
|
||||||
def ensure_osm() -> Path:
|
def ensure_osm() -> Path:
|
||||||
osm = C.DATA_DIR / "osm" / "region.osm.pbf"
|
"""Schienennetz aus OpenStreetMap für alle Länder: herunterladen, mit osmium auf Bahn-Objekte
|
||||||
if not osm.exists() or time.time() - osm.stat().st_mtime > 30 * 86400:
|
filtern (Deutschland: ~4 GB -> wenige 100 MB) und zusammenführen. Monatlich erneuert."""
|
||||||
osm.parent.mkdir(parents=True, exist_ok=True)
|
d = C.DATA_DIR / "osm"
|
||||||
log.info("Lade OSM-Daten %s", C.OSM_URL)
|
rail, urls_file = d / "rail.osm.pbf", d / "urls.txt"
|
||||||
download(C.OSM_URL, osm)
|
wanted = "\n".join(C.OSM_URLS)
|
||||||
return osm
|
if rail.exists() and time.time() - rail.stat().st_mtime < 30 * 86400 \
|
||||||
|
and urls_file.exists() and urls_file.read_text() == wanted:
|
||||||
|
return rail
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
parts = []
|
||||||
|
for i, url in enumerate(C.OSM_URLS):
|
||||||
|
raw, part = d / f"download-{i}.osm.pbf", d / f"rail-{i}.osm.pbf"
|
||||||
|
log.info("Lade OSM-Daten %s", url)
|
||||||
|
download(url, raw)
|
||||||
|
log.info("Filtere Schienennetz …")
|
||||||
|
subprocess.run([C.OSMIUM, "tags-filter", str(raw), "nwr/railway", "nwr/public_transport", "r/route",
|
||||||
|
"-o", str(part), "--overwrite"], check=True)
|
||||||
|
raw.unlink() # spart Platz
|
||||||
|
parts.append(str(part))
|
||||||
|
tmp = d / "rail.tmp.osm.pbf"
|
||||||
|
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(tmp), "--overwrite"], check=True)
|
||||||
|
tmp.rename(rail)
|
||||||
|
urls_file.write_text(wanted)
|
||||||
|
for p in parts:
|
||||||
|
Path(p).unlink()
|
||||||
|
for old in d.glob("region.osm.pbf"): # Datei der alten Version (nur Schweiz)
|
||||||
|
old.unlink()
|
||||||
|
return rail
|
||||||
|
|
||||||
|
|
||||||
def run_pfaedle(work: Path, rail: Path) -> Path | None:
|
def run_pfaedle(work: Path, rail: Path) -> Path | None:
|
||||||
@@ -202,9 +249,12 @@ def pack_shapes(d: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_tiles() -> None:
|
def ensure_tiles() -> None:
|
||||||
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen."""
|
"""Kartenkacheln (OpenStreetMap, von Protomaps) einmalig für die Region herunterladen
|
||||||
|
(erneut, wenn sich der Ausschnitt geändert hat)."""
|
||||||
dest = C.DATA_DIR / "tiles" / "region.pmtiles"
|
dest = C.DATA_DIR / "tiles" / "region.pmtiles"
|
||||||
if dest.exists() and dest.stat().st_size:
|
info = C.DATA_DIR / "tiles" / "region.txt"
|
||||||
|
wanted = f"{C.TILES_BBOX} z{C.TILES_MAXZOOM}"
|
||||||
|
if dest.exists() and dest.stat().st_size and info.exists() and info.read_text() == wanted:
|
||||||
return
|
return
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = dest.with_suffix(".tmp")
|
tmp = dest.with_suffix(".tmp")
|
||||||
@@ -212,55 +262,48 @@ def ensure_tiles() -> None:
|
|||||||
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
|
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
|
||||||
log.info("Lade Kartenkacheln vom %s …", d)
|
log.info("Lade Kartenkacheln vom %s …", d)
|
||||||
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
|
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
|
||||||
f"--bbox={C.TILES_BBOX}", "--maxzoom=15"])
|
f"--bbox={C.TILES_BBOX}", f"--maxzoom={C.TILES_MAXZOOM}"])
|
||||||
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
|
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
|
||||||
tmp.rename(dest)
|
tmp.rename(dest)
|
||||||
|
info.write_text(wanted)
|
||||||
return
|
return
|
||||||
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
|
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
|
||||||
|
|
||||||
|
|
||||||
def publish(src: Path, meta: dict) -> None:
|
def publish(source: str, src: Path, meta: dict) -> None:
|
||||||
(src / "meta.json").write_text(json.dumps(meta))
|
(src / "meta.json").write_text(json.dumps(meta))
|
||||||
dest = C.FEEDS_DIR / meta["feed_version"]
|
base = C.FEEDS_DIR / source
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = base / meta["feed_version"]
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
shutil.rmtree(dest)
|
shutil.rmtree(dest)
|
||||||
src.rename(dest) # gleiche Partition -> atomar; der Server sieht nur fertige Ordner
|
src.rename(dest) # gleiche Partition -> atomar; der Server sieht nur fertige Ordner
|
||||||
for old in sorted(p for p in C.FEEDS_DIR.iterdir() if p.is_dir())[:-3]:
|
for old in sorted(p for p in base.iterdir() if p.is_dir())[:-2]:
|
||||||
shutil.rmtree(old)
|
shutil.rmtree(old)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool = False) -> None:
|
||||||
ap = argparse.ArgumentParser()
|
|
||||||
ap.add_argument("--zip", type=Path, help="lokale GTFS-Zip statt Download")
|
|
||||||
ap.add_argument("--force", action="store_true")
|
|
||||||
args = ap.parse_args()
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
||||||
|
|
||||||
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
if not args.zip:
|
|
||||||
ensure_tiles()
|
|
||||||
state_file = C.DATA_DIR / "state.json"
|
|
||||||
state = json.loads(state_file.read_text()) if state_file.exists() else {}
|
|
||||||
work = C.DATA_DIR / "work"
|
work = C.DATA_DIR / "work"
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
work.mkdir()
|
work.mkdir()
|
||||||
|
|
||||||
if args.zip:
|
version = None
|
||||||
zip_path, name = args.zip, args.zip.name
|
if zip_path:
|
||||||
|
name = zip_path.name
|
||||||
else:
|
else:
|
||||||
url = gtfs_url()
|
url = gtfs_url(source)
|
||||||
name = latest_gtfs_name(url)
|
name, version = remote_id(source, url)
|
||||||
if name == state.get("last_file") and not args.force:
|
if name == state.get(source) and not force:
|
||||||
log.info("Kein neuer Fahrplan (%s)", name)
|
log.info("[%s] Kein neuer Fahrplan (%s)", source, name)
|
||||||
return
|
return
|
||||||
zip_path = work / name
|
zip_path = work / "gtfs.zip"
|
||||||
log.info("Lade %s", name)
|
log.info("[%s] Lade Fahrplan %s", source, name)
|
||||||
download(url, zip_path)
|
download(url, zip_path)
|
||||||
|
|
||||||
t = time.time()
|
t = time.time()
|
||||||
meta = filter_rail(zip_path, work / "rail")
|
meta = filter_rail(zip_path, work / "rail", version)
|
||||||
log.info("Bahn-Filter: %s in %.0f s", meta, time.time() - t)
|
log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
|
||||||
if not args.zip:
|
if zip_path.parent == work:
|
||||||
zip_path.unlink()
|
zip_path.unlink()
|
||||||
|
|
||||||
shaped = run_pfaedle(work, work / "rail")
|
shaped = run_pfaedle(work, work / "rail")
|
||||||
@@ -269,12 +312,41 @@ def main() -> None:
|
|||||||
pack_shapes(shaped)
|
pack_shapes(shaped)
|
||||||
meta["source_file"] = name
|
meta["source_file"] = name
|
||||||
meta["prepared_at"] = int(time.time())
|
meta["prepared_at"] = int(time.time())
|
||||||
publish(shaped or work / "rail", meta)
|
publish(source, shaped or work / "rail", meta)
|
||||||
|
|
||||||
state["last_file"] = name
|
|
||||||
state_file.write_text(json.dumps(state))
|
|
||||||
shutil.rmtree(work, ignore_errors=True)
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
log.info("Fahrplan %s bereit", meta["feed_version"])
|
state[source] = name
|
||||||
|
log.info("[%s] Fahrplan %s bereit", source, meta["feed_version"])
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--source", choices=list(C.SOURCES), help="nur diese Quelle (Standard: alle aktiven)")
|
||||||
|
ap.add_argument("--zip", type=Path, help="lokale GTFS-Zip statt Download (nur mit --source)")
|
||||||
|
ap.add_argument("--force", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
if args.zip and not args.source:
|
||||||
|
ap.error("--zip braucht --source")
|
||||||
|
|
||||||
|
C.FEEDS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
for p in C.FEEDS_DIR.iterdir(): # Ordner der alten Version (feeds/<version>) und abgeschaltete Quellen
|
||||||
|
if p.is_dir() and p.name not in C.SOURCES:
|
||||||
|
shutil.rmtree(p)
|
||||||
|
if not args.zip:
|
||||||
|
ensure_tiles()
|
||||||
|
state_file = C.DATA_DIR / "state.json"
|
||||||
|
state = json.loads(state_file.read_text()) if state_file.exists() else {}
|
||||||
|
|
||||||
|
failed = False
|
||||||
|
for source in [args.source] if args.source else C.ENABLED:
|
||||||
|
try:
|
||||||
|
prepare(source, state, args.zip, args.force)
|
||||||
|
except Exception: # eine kaputte Quelle soll die anderen nicht aufhalten
|
||||||
|
log.exception("[%s] Aufbereitung fehlgeschlagen", source)
|
||||||
|
failed = True
|
||||||
|
state_file.write_text(json.dumps(state))
|
||||||
|
if failed:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ def parse(data: bytes, known_trips: dict):
|
|||||||
return version, out
|
return version, out
|
||||||
|
|
||||||
|
|
||||||
def fetch() -> bytes:
|
def fetch(url: str, key: str = "") -> bytes:
|
||||||
r = httpx.get(C.RT_URL, follow_redirects=True, timeout=30, headers={
|
headers = {"User-Agent": C.USER_AGENT}
|
||||||
"Authorization": f"Bearer {C.RT_KEY}", "User-Agent": C.USER_AGENT})
|
if key:
|
||||||
|
headers["Authorization"] = f"Bearer {key}"
|
||||||
|
r = httpx.get(url, follow_redirects=True, timeout=30, headers=headers)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.content
|
return r.content
|
||||||
|
|||||||
+89
-52
@@ -15,7 +15,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import config as C
|
from . import config as C
|
||||||
from . import realtime
|
from . import border, realtime
|
||||||
from .feed import TZ, Feed
|
from .feed import TZ, Feed
|
||||||
|
|
||||||
log = logging.getLogger("trainpulse")
|
log = logging.getLogger("trainpulse")
|
||||||
@@ -23,13 +23,25 @@ STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Bro
|
|||||||
WEB = Path(__file__).resolve().parent.parent / "web"
|
WEB = Path(__file__).resolve().parent.parent / "web"
|
||||||
|
|
||||||
|
|
||||||
|
class Source:
|
||||||
|
"""Ein Fahrplan (Schweiz, Deutschland, …) mit seinem Echtzeit-Feed."""
|
||||||
|
|
||||||
|
def __init__(self, name: str):
|
||||||
|
self.name, self.cfg = name, C.SOURCES[name]
|
||||||
|
self.feed: Feed | None = None
|
||||||
|
self.rt: dict = {}
|
||||||
|
self.rt_version: str | None = None
|
||||||
|
self.rt_at: float | None = None
|
||||||
|
self.rt_error: str | None = None
|
||||||
|
self.rt_tried_at: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_rt(self) -> bool:
|
||||||
|
return bool(self.cfg["rt_url"]) and (bool(self.cfg["rt_key"]) or not self.cfg["needs_key"])
|
||||||
|
|
||||||
|
|
||||||
class State:
|
class State:
|
||||||
feed: Feed | None = None
|
sources = {name: Source(name) for name in C.ENABLED}
|
||||||
rt: dict = {}
|
|
||||||
rt_version: str | None = None
|
|
||||||
rt_at: float | None = None
|
|
||||||
rt_error: str | None = None
|
|
||||||
rt_tried_at: float | None = None
|
|
||||||
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
|
vehicles: bytes = b'{"type":"FeatureCollection","features":[]}'
|
||||||
vehicles_gz: bytes = gzip.compress(vehicles)
|
vehicles_gz: bytes = gzip.compress(vehicles)
|
||||||
count = 0
|
count = 0
|
||||||
@@ -39,62 +51,78 @@ class State:
|
|||||||
S = State()
|
S = State()
|
||||||
|
|
||||||
|
|
||||||
def loop(every: float, fn):
|
def loop(every: float, fn, *args):
|
||||||
def run():
|
def run():
|
||||||
while True:
|
while True:
|
||||||
t = time.time()
|
t = time.time()
|
||||||
try:
|
try:
|
||||||
fn()
|
fn(*args)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Fehler in %s", fn.__name__)
|
log.exception("Fehler in %s %s", fn.__name__, args)
|
||||||
time.sleep(max(1.0, every - (time.time() - t)))
|
time.sleep(max(1.0, every - (time.time() - t)))
|
||||||
threading.Thread(target=run, daemon=True, name=fn.__name__).start()
|
threading.Thread(target=run, daemon=True, name=fn.__name__).start()
|
||||||
|
|
||||||
|
|
||||||
def watch_feeds():
|
def watch_feeds():
|
||||||
"""Lädt den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (die schalten
|
"""Lädt je Quelle den passenden Fahrplan: den, auf den sich die Echtzeitdaten beziehen (Schweiz:
|
||||||
erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
|
die schalten erst um 15 Uhr auf einen neuen Fahrplan um), sonst den neusten."""
|
||||||
ready = sorted(m.parent.name for m in C.FEEDS_DIR.glob("*/meta.json"))
|
|
||||||
if not ready:
|
|
||||||
return
|
|
||||||
want = S.rt_version if S.rt_version in ready else ready[-1]
|
|
||||||
today = dt.datetime.now(TZ).date()
|
today = dt.datetime.now(TZ).date()
|
||||||
if S.feed and S.feed.version == want and S.feed.covers(today + dt.timedelta(days=1)):
|
for src in S.sources.values():
|
||||||
return
|
ready = sorted(m.parent.name for m in (C.FEEDS_DIR / src.name).glob("*/meta.json"))
|
||||||
log.info("Lade Fahrplan %s …", want)
|
if not ready:
|
||||||
|
continue
|
||||||
|
want = src.rt_version if src.rt_version in ready else ready[-1]
|
||||||
|
if src.feed and src.feed.version == want and src.feed.covers(today + dt.timedelta(days=1)):
|
||||||
|
continue
|
||||||
|
log.info("[%s] Lade Fahrplan %s …", src.name, want)
|
||||||
t = time.time()
|
t = time.time()
|
||||||
S.feed = Feed(C.FEEDS_DIR / want)
|
src.feed = Feed(C.FEEDS_DIR / src.name / want)
|
||||||
log.info("Fahrplan %s geladen in %.0f s (%d Fahrten)", want, time.time() - t, len(S.feed.trip_ids))
|
log.info("[%s] Fahrplan %s geladen in %.0f s (%d Fahrten)", src.name, want, time.time() - t,
|
||||||
|
len(src.feed.trip_ids))
|
||||||
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
|
try: # Echtzeit sofort holen statt auf den nächsten Abruf zu warten
|
||||||
poll_realtime()
|
poll_realtime(src)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Erster Echtzeit-Abruf fehlgeschlagen")
|
log.exception("[%s] Erster Echtzeit-Abruf fehlgeschlagen", src.name)
|
||||||
compute_positions()
|
compute_positions()
|
||||||
|
|
||||||
|
|
||||||
def poll_realtime():
|
def poll_realtime(src: Source):
|
||||||
if not C.RT_KEY or not S.feed:
|
if not src.has_rt or not src.feed:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
S.rt_version, S.rt = realtime.parse(realtime.fetch(), S.feed.trip_index)
|
src.rt_version, src.rt = realtime.parse(realtime.fetch(src.cfg["rt_url"], src.cfg["rt_key"]),
|
||||||
S.rt_at, S.rt_error = time.time(), None
|
src.feed.trip_index)
|
||||||
|
src.rt_at, src.rt_error = time.time(), None
|
||||||
except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen
|
except Exception as e: # Daten älter als 10 min sind wertlos -> verwerfen statt falsch anzeigen
|
||||||
S.rt_error = str(e)
|
src.rt_error = str(e)
|
||||||
if S.rt_at and time.time() - S.rt_at > 600:
|
if src.rt_at and time.time() - src.rt_at > 600:
|
||||||
S.rt = {}
|
src.rt = {}
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
S.rt_tried_at = time.time()
|
src.rt_tried_at = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def positions(src: Source, t: float) -> list:
|
||||||
|
show = border.SHOW.get(src.name) if len(S.sources) > 1 else None
|
||||||
|
feats = []
|
||||||
|
for f in src.feed.vehicles(t, src.rt):
|
||||||
|
if show and not show(*f["geometry"]["coordinates"]):
|
||||||
|
continue # gehört zum Fahrplan des Nachbarlandes
|
||||||
|
f["properties"]["id"] = f"{src.name}/{f['properties']['id']}"
|
||||||
|
feats.append(f)
|
||||||
|
return feats
|
||||||
|
|
||||||
|
|
||||||
def compute_positions():
|
def compute_positions():
|
||||||
if not S.feed:
|
|
||||||
return
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
feats = S.feed.vehicles(now, S.rt)
|
feats, future = [], [{} for _ in range(STEPS)]
|
||||||
future = [{f["properties"]["id"]: f["geometry"]["coordinates"] + [f["properties"]["heading"]]
|
for src in list(S.sources.values()):
|
||||||
for f in S.feed.vehicles(now + STEP * k, S.rt)}
|
if not src.feed:
|
||||||
for k in range(1, STEPS + 1)]
|
continue
|
||||||
|
feats += positions(src, now)
|
||||||
|
for k in range(STEPS):
|
||||||
|
future[k].update({f"{src.name}/{f['properties']['id']}": f["geometry"]["coordinates"] + [f["properties"]["heading"]]
|
||||||
|
for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)})
|
||||||
for f in feats:
|
for f in feats:
|
||||||
path = []
|
path = []
|
||||||
for pos in future:
|
for pos in future:
|
||||||
@@ -114,7 +142,8 @@ 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")
|
||||||
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)
|
||||||
loop(C.RT_INTERVAL, poll_realtime)
|
for src in S.sources.values():
|
||||||
|
loop(src.cfg["rt_interval"], poll_realtime, src)
|
||||||
loop(3, compute_positions)
|
loop(3, compute_positions)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -130,28 +159,36 @@ def vehicles(request: Request):
|
|||||||
return Response(S.vehicles, media_type="application/json", headers=headers)
|
return Response(S.vehicles, media_type="application/json", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/trip/{date}/{ti}")
|
@app.get("/api/trip/{source}/{date}/{ti}")
|
||||||
def trip(date: str, ti: int):
|
def trip(source: str, date: str, ti: int):
|
||||||
f = S.feed
|
src = S.sources.get(source)
|
||||||
|
f = src.feed if src else None
|
||||||
if not f or not (0 <= ti < len(f.trip_ids)) or not (len(date) == 8 and date.isdigit()):
|
if not f or not (0 <= ti < len(f.trip_ids)) or not (len(date) == 8 and date.isdigit()):
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
return f.trip_detail(date, ti, time.time(), S.rt)
|
return f.trip_detail(date, ti, time.time(), src.rt)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def status():
|
def status():
|
||||||
f = S.feed
|
loaded = [s for s in S.sources.values() if s.feed]
|
||||||
|
rt = [s for s in loaded if s.has_rt]
|
||||||
|
ok = [s for s in rt if not s.rt_error and s.rt_at and time.time() - s.rt_at < 180]
|
||||||
return {
|
return {
|
||||||
"feed_version": f.version if f else None,
|
"loaded": bool(loaded),
|
||||||
"shapes": f.meta.get("shapes") if f else None,
|
|
||||||
"realtime": bool(C.RT_KEY),
|
|
||||||
"rt_version": S.rt_version,
|
|
||||||
"rt_age": round(time.time() - S.rt_at) if S.rt_at else None,
|
|
||||||
"rt_error": S.rt_error,
|
|
||||||
"rt_trips": len(S.rt),
|
|
||||||
"vehicles": S.count,
|
|
||||||
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
|
# erst "bereit", wenn Positionen mit (dem ersten Versuch) Echtzeit berechnet sind
|
||||||
"ready": bool(f and S.computed_at and (not C.RT_KEY or (S.rt_tried_at and S.computed_at >= S.rt_tried_at))),
|
"ready": bool(loaded and S.computed_at and all(s.rt_tried_at and S.computed_at >= s.rt_tried_at for s in rt)),
|
||||||
|
"vehicles": S.count,
|
||||||
|
"rt_ok": bool(ok) and len(ok) == len(loaded),
|
||||||
|
"rt_some": bool(ok),
|
||||||
|
"sources": {s.name: {
|
||||||
|
"feed_version": s.feed.version if s.feed else None,
|
||||||
|
"shapes": s.feed.meta.get("shapes") if s.feed else None,
|
||||||
|
"realtime": s.has_rt,
|
||||||
|
"rt_version": s.rt_version,
|
||||||
|
"rt_age": round(time.time() - s.rt_at) if s.rt_at else None,
|
||||||
|
"rt_error": s.rt_error,
|
||||||
|
"rt_trips": len(s.rt),
|
||||||
|
} for s in S.sources.values()},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+8
-6
@@ -25,8 +25,8 @@ const delayColor = ["case",
|
|||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: "map",
|
container: "map",
|
||||||
center: [8.23, 46.82],
|
center: [9.6, 49.2], // Deutschland + Schweiz
|
||||||
zoom: 7.4,
|
zoom: 5.6,
|
||||||
hash: true,
|
hash: true,
|
||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
@@ -50,7 +50,8 @@ const map = new maplibregl.Map({
|
|||||||
route: { type: "geojson", data: EMPTY },
|
route: { type: "geojson", data: EMPTY },
|
||||||
trains: {
|
trains: {
|
||||||
type: "geojson", data: EMPTY,
|
type: "geojson", data: EMPTY,
|
||||||
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)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
layers: [
|
layers: [
|
||||||
@@ -234,7 +235,7 @@ async function refreshStatus() {
|
|||||||
try {
|
try {
|
||||||
const s = await (await fetch("api/status")).json();
|
const s = await (await fetch("api/status")).json();
|
||||||
if (!s.ready) {
|
if (!s.ready) {
|
||||||
loading.textContent = s.feed_version ? "Züge werden geladen …"
|
loading.textContent = s.loaded ? "Züge werden geladen …"
|
||||||
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 15–20 Minuten.";
|
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 15–20 Minuten.";
|
||||||
loading.hidden = false;
|
loading.hidden = false;
|
||||||
return;
|
return;
|
||||||
@@ -244,8 +245,9 @@ async function refreshStatus() {
|
|||||||
await refreshTrains();
|
await refreshTrains();
|
||||||
loading.hidden = true;
|
loading.hidden = true;
|
||||||
}
|
}
|
||||||
const rtOk = s.realtime && !s.rt_error && s.rt_age !== null && s.rt_age < 180;
|
const rtOk = s.rt_ok;
|
||||||
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : " · Verspätungen zurzeit nicht verfügbar");
|
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : s.rt_some
|
||||||
|
? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
|
||||||
el.className = rtOk ? "" : "warn";
|
el.className = rtOk ? "" : "warn";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading.textContent = "Keine Verbindung zum Server …";
|
loading.textContent = "Keine Verbindung zum Server …";
|
||||||
|
|||||||
Reference in New Issue
Block a user