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:
@@ -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
@@ -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]}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user