Züge springen nicht mehr zurück bei steigender Verspätung; Ausfälle und Teilausfälle (SKIPPED) berücksichtigt; POI-Symbole ausgeblendet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
Claude
2026-10-22 09:18:50 +02:00
parent 1dd39c08db
commit 811480b4d3
5 changed files with 51 additions and 18 deletions
+16
View File
@@ -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 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 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 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 # Mindesthaltezeit: 0 s Halt wird für die Animation verlängert, Endhalte bleiben
from trainpulse.feed import with_min_dwell from trainpulse.feed import with_min_dwell
@@ -158,6 +159,21 @@ def test_all():
_, rt = realtime.parse(rt_feed(cancel=True), f.trip_index) _, rt = realtime.parse(rt_feed(cancel=True), f.trip_index)
assert f.vehicles(at(8, 5), rt) == [] 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 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) _, rt = realtime.parse(rt_feed(delay_b=120), f.trip_index)
+13 -9
View File
@@ -283,7 +283,7 @@ class Feed:
for j in range(bisect_right(cum, d0), bisect_right(cum, d1)): for j in range(bisect_right(cum, d0), bisect_right(cum, d1)):
if d0 < cum[j] < d1: if d0 < cum[j] < d1:
out.append((cum[j], (k + (cum[j] - d0) / (d1 - d0)) * step)) 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) return simplify(res, tolerance, fixed)
@staticmethod @staticmethod
@@ -299,11 +299,12 @@ class Feed:
def times(self, ti: int, ds: float, upd): def times(self, ti: int, ds: float, upd):
"""Ankunft/Abfahrt je Halt (Sekunden ab Betriebstag), mit Verspätungen aus GTFS-RT. """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] o0, o1 = self.off[ti], self.off[ti + 1]
arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1]) arr, dep = list(self.st_arr[o0:o1]), list(self.st_dep[o0:o1])
if not upd: if not upd:
return arr, dep return arr, dep, set()
seqs, stops = self.st_seq[o0:o1], self.st_stop[o0:o1] seqs, stops = self.st_seq[o0:o1], self.st_stop[o0:o1]
by_i, j = {}, 0 by_i, j = {}, 0
for u in upd: for u in upd:
@@ -339,7 +340,7 @@ class Feed:
a = max(a, prev) a = max(a, prev)
d = max(d, a) d = max(d, a)
arr[i], dep[i], prev = a, d, d 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 ---------- # ---------- Positionen ----------
@@ -357,8 +358,11 @@ class Feed:
r = rt.get((self.trip_ids[ti], dstr)) r = rt.get((self.trip_ids[ti], dstr))
if r and r[0]: if r and r[0]:
continue # ausgefallen continue # ausgefallen
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)
if not (dep[0] <= t <= arr[-1]): # 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 continue
pa, pd = with_min_dwell(arr, dep) # nur für die Position; Zeiten/Verspätung bleiben echt 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) 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() 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 = 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] o0, o1 = self.off[ti], self.off[ti + 1]
def hhmm(sec): def hhmm(sec):
@@ -410,10 +414,10 @@ class Feed:
"dep": hhmm(self.st_dep[i]) if i < o1 - 1 else None, "dep": hhmm(self.st_dep[i]) if i < o1 - 1 else None,
"arr_delay": arr[k] - self.st_arr[i] if r 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, "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]] route = self.routes[self.trip_route[ti]]
return {"line": route["line"], "cat": route["cat"], "agency": route["agency"], return {"line": 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]), "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]}
+4 -2
View File
@@ -5,10 +5,11 @@ from google.transit import gtfs_realtime_pb2 as pb
from . import config as C from . import config as C
CANCELED = pb.TripDescriptor.CANCELED CANCELED = pb.TripDescriptor.CANCELED
SKIPPED = pb.TripUpdate.StopTimeUpdate.SKIPPED
def parse(data: bytes, known_trips: dict): 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). 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.""" ank/abf sind Verspätung in s, oder Unix-Zeit wenn *_absolut; None = keine Angabe."""
feed = pb.FeedMessage() 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] ev += [x.time, True] if x.HasField("time") else [x.delay, False] if x.HasField("delay") 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,
s.schedule_relationship == SKIPPED))
out[(tu.trip.trip_id, tu.trip.start_date)] = (tu.trip.schedule_relationship == CANCELED, upd) 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 version = feed.header.feed_version if feed.header.HasField("feed_version") else None
return version, out return version, out
+17 -7
View File
@@ -12,7 +12,7 @@ function centerPlace(l) {
const { "icon-image": _i, "text-variable-anchor": _a, "text-radial-offset": _o, ...rest } = l.layout; const { "icon-image": _i, "text-variable-anchor": _a, "text-radial-offset": _o, ...rest } = l.layout;
return { ...rest, "text-anchor": "center" }; 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 : {}) } })); layout: { ...centerPlace(l), visibility: "none" }, paint: { ...l.paint, ...(l.type === "symbol" ? extra : {}) } }));
const LAYERS = { const LAYERS = {
light: prefixed(basemaps.layers("osm", basemaps.namedFlavor("light"), { lang: "de" }), "l-"), 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 now = Number(r.headers.get("X-Now"));
const d = await r.json(); const d = await r.json();
if (now) clockOffset = Date.now() / 1000 - now; 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) { for (const f of d.features) {
f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts] f.pts = f.path && f.path.length ? f.path : [[...f.geometry.coordinates, 0]]; // [lon, lat, s ab ts, m ab Fahrtbeginn]
const b = old.get(f.properties.id); const o = old.get(f.properties.id);
if (b !== undefined) f.properties.bearing = b; 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; trains = d;
ts = d.ts; ts = d.ts;
@@ -224,6 +228,7 @@ map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
}, 26)), { sdf: true, pixelRatio: 2 }); }, 26)), { sdf: true, pixelRatio: 2 });
let lastRender = 0; let lastRender = 0;
const MAX_HOLD_M = 5000; // ~1 min eines schnellen Zuges
function render() { function render() {
const t = Date.now() / 1000 - clockOffset - ts; // s seit Berechnung auf dem Server 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 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++; 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 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; 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]; 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) // Richtung aus dem aktuellen Gleisstück; steht der Zug, die vom Server (Gleis am Halt)
const target = bearing(a, b) ?? f.properties.heading; const target = bearing(a, b) ?? f.properties.heading;
@@ -320,10 +330,10 @@ async function showTrip(id) {
<h2>${esc(t.line)} ${esc(t.nr)} ${t.canceled ? '<span class="badge" style="background:var(--d3)">fällt aus</span>' : delayBadge(curDelay)}</h2> <h2>${esc(t.line)} ${esc(t.nr)} ${t.canceled ? '<span class="badge" style="background:var(--d3)">fällt aus</span>' : delayBadge(curDelay)}</h2>
<p class="sub">nach ${esc(t.to)} · ${esc(t.agency)}</p> <p class="sub">nach ${esc(t.to)} · ${esc(t.agency)}</p>
<ul class="stops">${t.stops.map((s) => ` <ul class="stops">${t.stops.map((s) => `
<li class="${s.passed ? "passed" : ""}"> <li class="${s.passed ? "passed" : ""} ${s.skipped ? "skipped" : ""}">
<span class="t">${esc(s.arr ?? s.dep)}</span> <span class="t">${esc(s.arr ?? s.dep)}</span>
<span>${esc(s.name)}${s.platform ? ` <span class="pl">Gl. ${esc(s.platform)}</span>` : ""}</span> <span>${esc(s.name)}${s.platform ? ` <span class="pl">Gl. ${esc(s.platform)}</span>` : ""}</span>
${delayText(s.arr !== null ? s.arr_delay : s.dep_delay)} ${s.skipped ? '<span class="d" style="color:var(--d3)">fällt aus</span>' : delayText(s.arr !== null ? s.arr_delay : s.dep_delay)}
</li>`).join("")} </li>`).join("")}
</ul>`; </ul>`;
document.getElementById("panel").hidden = false; document.getElementById("panel").hidden = false;
+1
View File
@@ -36,6 +36,7 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord
.stops { list-style: none; margin: 0; padding: 0; } .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 { 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.passed { opacity: .45; }
.stops li.skipped > span:nth-child(-n+2) { text-decoration: line-through; }
.stops .t { font-variant-numeric: tabular-nums; } .stops .t { font-variant-numeric: tabular-nums; }
.stops .d { font-size: 12px; font-weight: 600; } .stops .d { font-size: 12px; font-weight: 600; }
.stops .pl { color: var(--muted); font-size: 12px; } .stops .pl { color: var(--muted); font-size: 12px; }