diff --git a/tests/test_core.py b/tests/test_core.py index 5b282a6..64b5629 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -122,6 +122,7 @@ def test_all(): assert tr[0][2] == 0 and tr[-1][2] == 4 and [p[2] for p in tr] == sorted(p[2] for p in tr) assert any(abs(p[0] - 8.05) < 1e-4 and abs(p[1] - 46.98) < 1e-4 for p in tr), tr # Scheitelpunkt drin assert len(f.track(0, [5000, 5000, 5000], 2)) == 3 # steht: Punkte bleiben (Zeit läuft) + assert tr[0][3] == 3000 and tr[-1][3] == 6000 # Strecken-km für "nie rückwärts" im Browser # Mindesthaltezeit: 0 s Halt wird für die Animation verlängert, Endhalte bleiben from trainpulse.feed import with_min_dwell @@ -158,6 +159,21 @@ def test_all(): _, rt = realtime.parse(rt_feed(cancel=True), f.trip_index) assert f.vehicles(at(8, 5), rt) == [] + def skipped(*seqs): # Ausfall einzelner Halte (so meldet Deutschland auch ganze Ausfälle) + fm = pb.FeedMessage() + fm.header.gtfs_realtime_version = "2.0" + e = fm.entity.add(id="1") + e.trip_update.trip.trip_id, e.trip_update.trip.start_date = "T1", "20261021" + for q in seqs: + e.trip_update.stop_time_update.add(stop_sequence=q).schedule_relationship = \ + pb.TripUpdate.StopTimeUpdate.SKIPPED + return realtime.parse(fm.SerializeToString(), f.trip_index)[1] + assert f.vehicles(at(8, 5), skipped(1, 2, 3)) == [] # alle Halte fallen aus = Zug fällt aus + rt = skipped(3) # endet vorzeitig in Beta + assert f.vehicles(at(8, 5), rt) and f.vehicles(at(8, 15), rt) == [] + assert [x["skipped"] for x in f.trip_detail("20261021", 0, at(8, 5), rt)["stops"]] == [False, False, True] + assert f.trip_detail("20261021", 0, at(8, 5), skipped(1, 2, 3))["canceled"] + assert f.vehicles(dt.datetime(2026, 10, 25, 8, 5, tzinfo=TZ).timestamp(), {}) == [] # calendar_dates _, rt = realtime.parse(rt_feed(delay_b=120), f.trip_index) diff --git a/trainpulse/feed.py b/trainpulse/feed.py index 68885c0..07754e7 100644 --- a/trainpulse/feed.py +++ b/trainpulse/feed.py @@ -283,7 +283,7 @@ class Feed: for j in range(bisect_right(cum, d0), bisect_right(cum, d1)): if d0 < cum[j] < d1: out.append((cum[j], (k + (cum[j] - d0) / (d1 - d0)) * step)) - res = [[*self.point_at((pts, cum, None), d), round(t, 2)] for d, t in out] + res = [[*self.point_at((pts, cum, None), d), round(t, 2), round(d)] for d, t in out] # d: m ab Fahrtbeginn return simplify(res, tolerance, fixed) @staticmethod @@ -299,11 +299,12 @@ class Feed: def times(self, ti: int, ds: float, upd): """Ankunft/Abfahrt je Halt (Sekunden ab Betriebstag), mit Verspätungen aus GTFS-RT. - Eine Verspätung gilt für alle folgenden Halte, bis eine neue gemeldet wird.""" + Eine Verspätung gilt für alle folgenden Halte, bis eine neue gemeldet wird. + Dazu die Nummern (0 …) der ausfallenden Halte.""" o0, o1 = self.off[ti], self.off[ti + 1] arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1]) if not upd: - return arr, dep + return arr, dep, set() seqs, stops = self.st_seq[o0:o1], self.st_stop[o0:o1] by_i, j = {}, 0 for u in upd: @@ -339,7 +340,7 @@ class Feed: a = max(a, prev) d = max(d, a) arr[i], dep[i], prev = a, d, d - return arr, dep + return arr, dep, {i for i, u in by_i.items() if u[6]} # ---------- Positionen ---------- @@ -357,8 +358,11 @@ class Feed: r = rt.get((self.trip_ids[ti], dstr)) if r and r[0]: continue # ausgefallen - arr, dep = self.times(ti, ds, r[1] if r else None) - if not (dep[0] <= t <= arr[-1]): + arr, dep, skip = self.times(ti, ds, r[1] if r else None) + # Teilausfall: fährt nur zwischen erstem und letztem bedienten Halt; Deutschland meldet + # auch ganze Ausfälle so (alle Halte fallen aus) + live = [k for k in range(len(arr)) if k not in skip] + if not live or not (dep[live[0]] <= t <= arr[live[-1]]): continue pa, pd = with_min_dwell(arr, dep) # nur für die Position; Zeiten/Verspätung bleiben echt i = min(bisect_right(pd, t), len(pd) - 1) # Zug ist zwischen Halt i-1 und i (oder steht an Halt i) @@ -394,7 +398,7 @@ class Feed: d = dt.datetime.strptime(date, "%Y%m%d").date() ds = day_start(d) r = rt.get((self.trip_ids[ti], date)) - arr, dep = 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] def hhmm(sec): @@ -410,10 +414,10 @@ class Feed: "dep": hhmm(self.st_dep[i]) if i < o1 - 1 else None, "arr_delay": arr[k] - self.st_arr[i] if r else None, "dep_delay": dep[k] - self.st_dep[i] if r else None, - "passed": now - ds > dep[k], + "passed": now - ds > dep[k], "skipped": k in skip, }) route = self.routes[self.trip_route[ti]] return {"line": route["line"], "cat": route["cat"], "agency": route["agency"], "nr": self.trip_number[ti], "to": self.trip_headsign[ti], - "realtime": bool(r), "canceled": bool(r and r[0]), + "realtime": bool(r), "canceled": bool(r and r[0]) or len(skip) == len(stops), "stops": stops, "shape": self.geometry(ti)[0]} diff --git a/trainpulse/realtime.py b/trainpulse/realtime.py index f1b3ae1..5cc60d0 100644 --- a/trainpulse/realtime.py +++ b/trainpulse/realtime.py @@ -5,10 +5,11 @@ from google.transit import gtfs_realtime_pb2 as pb from . import config as C CANCELED = pb.TripDescriptor.CANCELED +SKIPPED = pb.TripUpdate.StopTimeUpdate.SKIPPED def parse(data: bytes, known_trips: dict): - """-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, stop_id, ank, ank_absolut, abf, abf_absolut), ...])}) + """-> (feed_version, {(trip_id, start_date): (ausgefallen, [(seq, stop_id, ank, ank_absolut, abf, abf_absolut, fällt_aus), ...])}) seq ist None, wenn die Quelle nur die Haltestellen-ID liefert (Frankreich, Niederlande). ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe.""" feed = pb.FeedMessage() @@ -29,7 +30,8 @@ def parse(data: bytes, known_trips: dict): ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") else [None, False] else: 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, + s.schedule_relationship == SKIPPED)) out[(tu.trip.trip_id, tu.trip.start_date)] = (tu.trip.schedule_relationship == CANCELED, upd) version = feed.header.feed_version if feed.header.HasField("feed_version") else None return version, out diff --git a/web/app.js b/web/app.js index 80da27e..1fa13f9 100644 --- a/web/app.js +++ b/web/app.js @@ -12,7 +12,7 @@ function centerPlace(l) { const { "icon-image": _i, "text-variable-anchor": _a, "text-radial-offset": _o, ...rest } = l.layout; return { ...rest, "text-anchor": "center" }; } -const prefixed = (layers, pre, extra = {}) => layers.filter((l) => l.id !== "roads_shields").map((l) => ({ ...l, id: pre + l.id, +const prefixed = (layers, pre, extra = {}) => layers.filter((l) => !["roads_shields", "pois"].includes(l.id)).map((l) => ({ ...l, id: pre + l.id, layout: { ...centerPlace(l), visibility: "none" }, paint: { ...l.paint, ...(l.type === "symbol" ? extra : {}) } })); const LAYERS = { light: prefixed(basemaps.layers("osm", basemaps.namedFlavor("light"), { lang: "de" }), "l-"), @@ -147,11 +147,15 @@ async function refreshTrains() { const now = Number(r.headers.get("X-Now")); const d = await r.json(); if (now) clockOffset = Date.now() / 1000 - now; - const old = new Map(trains.features.map((f) => [f.properties.id, f.properties.bearing])); + const old = new Map(trains.features.map((f) => [f.properties.id, f])); for (const f of d.features) { - f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts] - const b = old.get(f.properties.id); - if (b !== undefined) f.properties.bearing = b; + f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts, m ab Fahrtbeginn] + const o = old.get(f.properties.id); + if (o) { + f.properties.bearing = o.properties.bearing; + f.geometry.coordinates = o.geometry.coordinates; // für "nicht rückwärts", siehe render() + f.shownD = o.shownD; + } } trains = d; ts = d.ts; @@ -224,6 +228,7 @@ map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => { }, 26)), { sdf: true, pixelRatio: 2 }); let lastRender = 0; +const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges function render() { const t = Date.now() / 1000 - clockOffset - ts; // s seit Berechnung auf dem Server const dtFrame = Math.min(0.2, (performance.now() - lastRender) / 1000); // nach Ruckler nicht schlagartig drehen @@ -237,6 +242,11 @@ function render() { while (i + 1 < p.length && p[i + 1][2] <= t) i++; const a = p[i], b = p[Math.min(i + 1, p.length - 1)]; const k = b[2] > a[2] ? Math.max(0, Math.min(1, (t - a[2]) / (b[2] - a[2]))) : 0; + // Steigt die Verspätung (Echtzeit kommt in Minutenschritten), läge der Zug plötzlich hinter der + // gezeigten Stelle: dann warten statt zurückspringen, bis er wieder dort ist (grosse Korrekturen ausgenommen) + const d = a[3] === undefined ? undefined : a[3] + (b[3] - a[3]) * k; + if (d !== undefined && f.shownD !== undefined && d < f.shownD && f.shownD - d < MAX_HOLD_M) continue; + f.shownD = d; f.geometry.coordinates = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; // Richtung aus dem aktuellen Gleisstück; steht der Zug, die vom Server (Gleis am Halt) const target = bearing(a, b) ?? f.properties.heading; @@ -320,10 +330,10 @@ async function showTrip(id) {

${esc(t.line)} ${esc(t.nr)} ${t.canceled ? 'fällt aus' : delayBadge(curDelay)}

nach ${esc(t.to)} · ${esc(t.agency)}

`; document.getElementById("panel").hidden = false; diff --git a/web/style.css b/web/style.css index 96eb222..cff5595 100644 --- a/web/style.css +++ b/web/style.css @@ -36,6 +36,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord .stops { list-style: none; margin: 0; padding: 0; } .stops li { display: grid; grid-template-columns: 46px 1fr auto; gap: 0 8px; padding: 5px 0; border-top: 1px solid var(--line); } .stops li.passed { opacity: .45; } +.stops li.skipped > span:nth-child(-n+2) { text-decoration: line-through; } .stops .t { font-variant-numeric: tabular-nums; } .stops .d { font-size: 12px; font-weight: 600; } .stops .pl { color: var(--muted); font-size: 12px; }