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 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)
+13 -9
View File
@@ -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]}
+4 -2
View File
@@ -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
+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;
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) {
<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>
<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>${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("")}
</ul>`;
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 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; }