Files
Trainpulse/trainpulse/prepare.py
T

380 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fahrplan-Update: holt für jede Quelle (Schweiz, Deutschland) den GTFS-Fahrplan, filtert auf Bahn
und rechnet mit pfaedle die echten Streckenverläufe (shapes) aus OpenStreetMap. Läuft stündlich
und macht für eine Quelle nichts, solange dort kein neuer Fahrplan veröffentlicht wurde.
python -m trainpulse.prepare # alle Quellen
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 csv
import datetime as dt
import io
import json
import logging
import shutil
import subprocess
import time
import zipfile
from array import array
from pathlib import Path
import httpx
from . import border
from . import config as C
from .feed import route_mode
log = logging.getLogger("prepare")
# Aufbereitungs-Version: ändert sich, was aus dem Fahrplan übernommen wird, rechnet der Updater
# alle Fahrpläne einmal neu, auch ohne neuen Fahrplan (2: mit Trams und U-Bahnen)
FORMAT = 2
def timetable_year(today: dt.date) -> int:
# Fahrplanwechsel ist am 2. Sonntag im Dezember
if today.month == 12:
first = dt.date(today.year, 12, 1)
if today >= first + dt.timedelta(days=(6 - first.weekday()) % 7 + 7):
return today.year + 1
return today.year
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
url = f"https://data.opentransportdata.swiss/dataset/timetable-{timetable_year(dt.date.today())}-gtfs2020/permalink"
return url
def download(url: str, dest: Path) -> None:
tmp = dest.with_suffix(dest.suffix + ".part")
with httpx.stream("GET", url, follow_redirects=True, timeout=120,
headers={"User-Agent": C.USER_AGENT}) as r:
r.raise_for_status()
total, done, t0, last = int(r.headers.get("content-length", 0)), 0, time.time(), time.time()
with open(tmp, "wb") as f:
for chunk in r.iter_bytes(1 << 20):
f.write(chunk)
done += len(chunk)
if time.time() - last > 30: # Fortschritt, damit ein langsamer Download nicht wie ein Hänger aussieht
last = time.time()
log.info(" … %d von %d MB (%.1f MB/s)", done >> 20, total >> 20, done / (last - t0) / 1e6)
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:
# 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.
r = httpx.get(url, follow_redirects=False, timeout=60, headers={"User-Agent": C.USER_AGENT})
if r.status_code not in (301, 302, 303, 307, 308):
r.raise_for_status()
raise RuntimeError(f"Keine Weiterleitung von {url} (Status {r.status_code})")
return r.headers["location"].rsplit("/", 1)[-1].split("?")[0]
def rows(z: zipfile.ZipFile, name: str):
reader = csv.reader(io.TextIOWrapper(z.open(name), encoding="utf-8-sig", newline=""))
header = next(reader)
return header, reader
def write_csv(path: Path, header, data) -> int:
n = 0
with open(path, "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(header)
for row in data:
w.writerow(row)
n += 1
return n
def filter_by_col(z: zipfile.ZipFile, name: str, col: str, keys: set, out: Path, collect: str | None = None):
"""Filter für die Riesendateien (stop_times.txt hat mehrere GB): behält Zeilen, deren Spalte `col`
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()
with z.open(name) as raw, open(out, "w", newline="", encoding="utf-8") as f:
lines = io.TextIOWrapper(raw, encoding="utf-8-sig", newline="")
header = next(csv.reader([lines.readline()]))
f.write(",".join(header) + "\n")
ki, ci = header.index(col), header.index(collect) if collect else None
for line in lines:
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)
n += 1
if ci is not None:
seen.add(row[ci])
return n, seen
def filter_rail(zip_path: Path, out: Path, version: str | None = None) -> dict:
out.mkdir(parents=True)
z = zipfile.ZipFile(zip_path)
h, rd = rows(z, "routes.txt")
routes = [r for r in rd if route_mode(r[h.index("route_type")])]
write_csv(out / "routes.txt", h, routes)
route_ids = {r[h.index("route_id")] for r in routes}
h, rd = rows(z, "trips.txt")
trips = [r for r in rd if r[h.index("route_id")] in route_ids]
if "shape_id" in h: # shapes.txt kopieren wir nicht (pfaedle rechnet neu) -> Verweise leeren (z. B. OVapi)
for r in trips:
r[h.index("shape_id")] = ""
write_csv(out / "trips.txt", h, trips)
trip_ids = {r[h.index("trip_id")] for r in trips}
service_ids = {r[h.index("service_id")] for r in trips}
if "frequencies.txt" in z.namelist():
fh, frd = rows(z, "frequencies.txt")
n = sum(1 for r in frd if r[fh.index("trip_id")] in trip_ids)
if n:
log.warning("%d Bahnfahrten nur über frequencies.txt – werden ignoriert", n)
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")
sid, par = h.index("stop_id"), h.index("parent_station")
stops = list(rd)
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[sid] in used_stops or r[sid] in parents))
for name in ("calendar.txt", "calendar_dates.txt"):
if name in z.namelist():
filter_by_col(z, name, "service_id", service_ids, out / name)
for name in ("agency.txt", "feed_info.txt"):
if name in z.namelist():
h, rd = rows(z, name)
write_csv(out / name, h, rd)
h, rd = rows(z, "feed_info.txt") if "feed_info.txt" in z.namelist() else ([], iter([]))
info = dict(zip(h, next(rd, [])))
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}
def ensure_osm() -> Path:
"""Schienennetz aus OpenStreetMap für alle Länder: herunterladen, mit osmium auf Bahn-Objekte
filtern (Deutschland: ~4 GB -> wenige 100 MB) und zusammenführen. Monatlich erneuert."""
d = C.DATA_DIR / "osm"
rail, urls_file = d / "rail.osm.pbf", d / "urls.txt"
wanted = "\n".join(C.OSM_URLS)
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))
merged, tmp = d / "rail.merged.osm.pbf", d / "rail.tmp.osm.pbf"
subprocess.run([C.OSMIUM, "merge", *parts, "-o", str(merged), "--overwrite"], check=True)
# Die Länder-Dateien sind unterschiedlich alt: Objekte an den Grenzen stecken dann in zwei
# Versionen drin, woran osmium extract scheitert. time-filter behält je Objekt die neueste.
subprocess.run([C.OSMIUM, "time-filter", str(merged), "-o", str(tmp), "--overwrite"], check=True)
# IDs durchnummerieren: osmium extract braucht sonst ~4 GB RAM für die weit verstreuten OSM-IDs
subprocess.run([C.OSMIUM, "renumber", str(tmp), "-o", str(merged), "--overwrite"], check=True)
tmp.unlink()
merged.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, source: str) -> Path | None:
if not shutil.which(C.PFAEDLE):
log.warning("pfaedle nicht gefunden – Züge fahren Luftlinie zwischen den Haltestellen")
return None
# Netz aufs Land (+ Rand) zuschneiden: die Fahrpläne enthalten Züge bis Paris oder Hamburg,
# pfaedle würde sonst das Netz aller Länder laden. Ausserhalb zeigt ohnehin der Nachbar-Fahrplan.
osm = work / "region.osm.pbf"
subprocess.run([C.OSMIUM, "extract", "-b", border.bbox(source), str(ensure_osm()), "-o", str(osm),
"--overwrite"], check=True)
log.info("[%s] pfaedle rechnet Streckenverläufe …", source)
t = time.time()
res = subprocess.run([C.PFAEDLE, "-D", "-m", "rail,tram,subway", "-x", str(osm), str(rail)], cwd=work)
osm.unlink()
out = work / "gtfs-out"
if res.returncode != 0 or not (out / "shapes.txt").exists():
log.error("[%s] pfaedle fehlgeschlagen (Code %s) – nutze Fahrplan ohne Streckenverläufe", source, res.returncode)
return None
log.info("[%s] pfaedle fertig in %.0f s", source, time.time() - t)
return out
def pack_shapes(d: Path) -> None:
"""shapes.txt ist für die Schweiz ~2.6 GB (56 Mio. Punkte) – viel zu gross für den Speicher.
Umwandeln in shapes.bin (float32: lon, lat, Meter ab Start) + shapes.json (id -> [Start, Anzahl]),
der Server liest daraus nur die Strecken, die er gerade braucht."""
src = d / "shapes.txt"
if not src.exists():
return
index, cur, pts, n = {}, None, [], 0
with open(d / "shapes.bin", "wb") as out:
def flush():
nonlocal n
if cur is not None:
pts.sort()
array("f", [v for p in pts for v in p[1:]]).tofile(out)
index[cur] = [n, len(pts)]
n += len(pts)
with open(src, encoding="utf-8-sig", newline="") as f:
r = csv.reader(f)
h = {k: i for i, k in enumerate(next(r))}
sid, lat, lon, seq = h["shape_id"], h["shape_pt_lat"], h["shape_pt_lon"], h["shape_pt_sequence"]
dist = h["shape_dist_traveled"]
for row in r:
if row[sid] != cur:
flush()
assert row[sid] not in index, "shapes.txt nicht nach shape_id gruppiert"
cur, pts = row[sid], []
pts.append((int(row[seq]), float(row[lon]), float(row[lat]), float(row[dist])))
flush()
(d / "shapes.json").write_text(json.dumps(index, separators=(",", ":")))
src.unlink()
def ensure_tiles() -> None:
"""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"
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
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(".tmp")
for days in range(1, 8): # Protomaps baut täglich, ältere Builds werden gelöscht
d = (dt.date.today() - dt.timedelta(days=days)).strftime("%Y%m%d")
log.info("Lade Kartenkacheln vom %s …", d)
r = subprocess.run([C.PMTILES, "extract", f"https://build.protomaps.com/{d}.pmtiles", str(tmp),
f"--bbox={C.TILES_BBOX}", f"--maxzoom={C.TILES_MAXZOOM}"])
if r.returncode == 0 and tmp.exists() and tmp.stat().st_size:
tmp.rename(dest)
info.write_text(wanted)
return
log.error("Kartenkacheln konnten nicht geladen werden – Karte bleibt ohne Hintergrund")
def publish(source: str, src: Path, meta: dict) -> None:
(src / "meta.json").write_text(json.dumps(meta))
base = C.FEEDS_DIR / source
base.mkdir(parents=True, exist_ok=True)
dest = base / meta["feed_version"]
if dest.exists():
shutil.rmtree(dest)
src.rename(dest) # gleiche Partition -> atomar; der Server sieht nur fertige Ordner
for old in sorted(p for p in base.iterdir() if p.is_dir())[:-2]:
shutil.rmtree(old)
def prepare(source: str, state: dict, zip_path: Path | None = None, force: bool = False) -> None:
work = C.DATA_DIR / "work"
shutil.rmtree(work, ignore_errors=True)
work.mkdir()
version = None
if zip_path:
name = zip_path.name
else:
url = gtfs_url(source)
name, version = remote_id(source, url)
if f"{name} #{FORMAT}" == state.get(source) and not force:
log.info("[%s] Kein neuer Fahrplan (%s)", source, name)
return
zip_path = work / "gtfs.zip"
log.info("[%s] Lade Fahrplan %s", source, name)
download(url, zip_path)
t = time.time()
meta = filter_rail(zip_path, work / "rail", version)
log.info("[%s] Bahn-Filter: %s in %.0f s", source, meta, time.time() - t)
if zip_path.parent == work:
zip_path.unlink()
shaped = run_pfaedle(work, work / "rail", source)
meta["shapes"] = shaped is not None
if shaped:
pack_shapes(shaped)
meta["source_file"] = name
meta["prepared_at"] = int(time.time())
publish(source, shaped or work / "rail", meta)
shutil.rmtree(work, ignore_errors=True)
state[source] = f"{name} #{FORMAT}"
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():
if not p.is_dir() or p.name in C.SOURCES:
continue
if p.name.isdigit() and (p / "meta.json").exists(): # alte Version: feeds/<version> war die Schweiz
(C.FEEDS_DIR / "ch").mkdir(exist_ok=True)
p.rename(C.FEEDS_DIR / "ch" / p.name)
else:
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__":
main()