Züge folgen dem Gleis statt gerader Linien, Mindesthaltezeit 40 s, osmium renumber

- Server schickt pro Zug die Gleispunkte der nächsten 12 s mit Zeitstempel
  (gerade Stücke auf ~3 m vereinfacht); Browser fährt sie ab, Richtung aus
  dem aktuellen Gleisstück
- Halte mit Ankunft = Abfahrt werden für die Animation auf 40 s verlängert
- OSM-IDs durchnummerieren: osmium extract braucht sonst ~4 GB RAM

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 07:29:00 +02:00
parent d339da5da2
commit 7ece7603a9
5 changed files with 118 additions and 34 deletions
+11
View File
@@ -114,6 +114,17 @@ def test_all():
assert abs(v[0]["properties"]["heading"] - 90) < 15, v[0]["properties"]["heading"] # Scheitel: Richtung Osten assert abs(v[0]["properties"]["heading"] - 90) < 15, v[0]["properties"]["heading"] # Scheitel: Richtung Osten
assert f.vehicles(at(8, 11), {})[0]["properties"]["heading"] is not None # auch im Stillstand assert f.vehicles(at(8, 11), {})[0]["properties"]["heading"] is not None # auch im Stillstand
# Gleisverlauf für den Browser: folgt der Kurve über den Umweg-Scheitel, Zeiten steigen an
tr = f.track(0, [3000, 4500, 6000], 2)
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)
# Mindesthaltezeit: 0 s Halt wird für die Animation verlängert, Endhalte bleiben
from trainpulse.feed import with_min_dwell
pa, pd = with_min_dwell([0, 600, 1200], [0, 600, 1200])
assert pa[1] == 580 and pd[1] == 620 and pa[0] == 0 and pd[2] == 1200
# Anfahren/Bremsen: symmetrisch, am Anfang/Ende langsamer als linear, Gesamtstrecke stimmt # Anfahren/Bremsen: symmetrisch, am Anfang/Ende langsamer als linear, Gesamtstrecke stimmt
from trainpulse.feed import travelled from trainpulse.feed import travelled
assert travelled(8000, 600, 0) == 0 and abs(travelled(8000, 600, 600) - 8000) < 1e-6 assert travelled(8000, 600, 0) == 0 and abs(travelled(8000, 600, 600) - 8000) < 1e-6
+66 -4
View File
@@ -76,6 +76,48 @@ def long_distance(cat: str, line: str) -> bool:
return bool(m) and m.group(0).upper() in LONG_DISTANCE return bool(m) and m.group(0).upper() in LONG_DISTANCE
MIN_DWELL = 40 # s; im Fahrplan steht oft Ankunft = Abfahrt, dann würde der Zug nie sichtbar halten
def with_min_dwell(arr, dep):
"""Halte mit weniger als MIN_DWELL Haltezeit für die Animation verlängern: etwas früher ankommen,
etwas später abfahren – höchstens ein Viertel der Fahrzeit davor/danach."""
pa, pd = list(arr), list(dep)
for k in range(1, len(arr) - 1):
short = MIN_DWELL - (dep[k] - arr[k])
if short > 0:
h = min(short / 2, (arr[k] - dep[k - 1]) / 4, (arr[k + 1] - dep[k]) / 4)
if h > 0:
pa[k], pd[k] = arr[k] - h, dep[k] + h
return pa, pd
def simplify(p: list, tol_m: float, fixed=()) -> list:
"""Douglas-Peucker auf [lon, lat, t]-Punkten (Toleranz in Metern, grob über die Breite umgerechnet).
Punkte in `fixed` bleiben immer erhalten."""
if len(p) < 3:
return p
kx = 111320 * math.cos(math.radians(p[0][1]))
keep = {0, len(p) - 1, *fixed}
cuts = sorted(keep)
stack = list(zip(cuts, cuts[1:]))
while stack:
a, b = stack.pop()
(x1, y1), (x2, y2) = (p[a][0] * kx, p[a][1] * 110540), (p[b][0] * kx, p[b][1] * 110540)
dx, dy = x2 - x1, y2 - y1
n = math.hypot(dx, dy)
best, idx = 0.0, -1
for i in range(a + 1, b):
x, y = p[i][0] * kx, p[i][1] * 110540
d = abs(dy * x - dx * y + x2 * y1 - y2 * x1) / n if n else math.hypot(x - x1, y - y1)
if d > best:
best, idx = d, i
if best > tol_m:
keep.add(idx)
stack += [(a, idx), (idx, b)]
return [p[i] for i in sorted(keep)]
class Feed: class Feed:
def __init__(self, path: Path, today: dt.date | None = None): def __init__(self, path: Path, today: dt.date | None = None):
self.path = path self.path = path
@@ -227,6 +269,23 @@ class Feed:
self._geom_cache[key] = g self._geom_cache[key] = g
return g return g
def track(self, ti: int, dists: list, step: float, tolerance: float = 3.0) -> list:
"""Gleispunkte von der aktuellen Position bis zur Position in len(dists)-1 Schritten, je mit der
Zeit (s ab jetzt), zu der der Zug dort ist. Gerade Stücke werden auf ~3 m vereinfacht."""
pts, cum, _ = self.geometry(ti)
out, fixed = [], []
for k in range(len(dists)):
d0 = dists[k]
fixed.append(len(out)) # Zeitpunkte alle `step` s behalten, sonst ginge das Bremsen verloren
out.append((d0, k * step))
if k + 1 < len(dists) and dists[k + 1] > d0:
d1 = dists[k + 1]
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]
return simplify(res, tolerance, fixed)
@staticmethod @staticmethod
def point_at(g, d: float): def point_at(g, d: float):
pts, cum, _ = g pts, cum, _ = g
@@ -299,14 +358,16 @@ class Feed:
arr, dep = self.times(ti, ds, r[1] if r else None) arr, dep = self.times(ti, ds, r[1] if r else None)
if not (dep[0] <= t <= arr[-1]): if not (dep[0] <= t <= arr[-1]):
continue continue
i = min(bisect_right(dep, t), len(dep) - 1) # Zug ist zwischen Halt i-1 und i (oder steht an Halt i) 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)
g = self.geometry(ti) g = self.geometry(ti)
sd = g[2] sd = g[2]
o = self.off[ti] o = self.off[ti]
if t >= arr[i]: # steht am Halt i if t >= pa[i]: # steht am Halt i
dist, delay = sd[i], dep[i] - self.st_dep[o + i] dist = sd[i]
delay = dep[i] - self.st_dep[o + i] if t >= arr[i] else arr[i] - self.st_arr[o + i]
else: else:
dist = sd[i - 1] + travelled(sd[i] - sd[i - 1], arr[i] - dep[i - 1], t - dep[i - 1]) dist = sd[i - 1] + travelled(sd[i] - sd[i - 1], pa[i] - pd[i - 1], t - pd[i - 1])
delay = arr[i] - self.st_arr[o + i] delay = arr[i] - self.st_arr[o + i]
lon, lat = self.point_at(g, dist) lon, lat = self.point_at(g, dist)
# Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand # Richtung aus dem Gleisverlauf (±25 m), stimmt auch im Stillstand
@@ -323,6 +384,7 @@ class Feed:
"delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]], "delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]],
"heading": heading, "heading": heading,
}, },
"_ti": ti, "_dist": dist, # intern, für track(); werden vor dem Versand entfernt
}) })
return feats return feats
+4 -2
View File
@@ -195,8 +195,10 @@ def ensure_osm() -> Path:
# Die Länder-Dateien sind unterschiedlich alt: Objekte an den Grenzen stecken dann in zwei # 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. # 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) subprocess.run([C.OSMIUM, "time-filter", str(merged), "-o", str(tmp), "--overwrite"], check=True)
merged.unlink() # IDs durchnummerieren: osmium extract braucht sonst ~4 GB RAM für die weit verstreuten OSM-IDs
tmp.rename(rail) subprocess.run([C.OSMIUM, "renumber", str(tmp), "-o", str(merged), "--overwrite"], check=True)
tmp.unlink()
merged.rename(rail)
urls_file.write_text(wanted) urls_file.write_text(wanted)
for p in parts: for p in parts:
Path(p).unlink() Path(p).unlink()
+16 -15
View File
@@ -19,7 +19,7 @@ from . import border, realtime
from .feed import TZ, Feed from .feed import TZ, Feed
log = logging.getLogger("trainpulse") log = logging.getLogger("trainpulse")
STEP, STEPS = 2, 5 # Positionen alle 2 s für die nächsten 10 s, damit der Browser Kurven nachfährt STEP, STEPS = 2, 6 # Gleisverlauf für die nächsten 12 s (Abruf alle 5 s, Berechnung alle 3 s -> Reserve)
WEB = Path(__file__).resolve().parent.parent / "web" WEB = Path(__file__).resolve().parent.parent / "web"
@@ -121,23 +121,24 @@ def positions(src: Source, t: float) -> list:
def compute_positions(): def compute_positions():
now = time.time() now = time.time()
feats, future = [], [{} for _ in range(STEPS)] feats = []
for src in list(S.sources.values()): for src in list(S.sources.values()):
if not src.feed: if not src.feed:
continue continue
feats += positions(src, now) cur = positions(src, now)
for k in range(STEPS): # Distanz entlang der Strecke alle STEP s; daraus der Gleisverlauf, dem der Browser folgt
future[k].update({f"{src.name}/{f['properties']['id']}": f["geometry"]["coordinates"] + [f["properties"]["heading"]] future = [{f["properties"]["id"]: f["_dist"] for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)}
for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)}) for k in range(STEPS)] # Schlüssel Betriebstag/Fahrt: Nachtzüge laufen über Mitternacht
for f in feats: for f in cur:
path = [] key, dists = f["properties"]["id"].split("/", 1)[1], [f["_dist"]]
for pos in future: for pos in future:
p = pos.get(f["properties"]["id"]) if key not in pos:
if p is None: break # Fahrt endet
break # Fahrt endet dists.append(pos[key])
path.append(p) f["path"] = src.feed.track(f.pop("_ti"), dists, STEP)
f["path"] = path del f["_dist"]
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "step": STEP, "features": feats}, feats += cur
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": feats},
separators=(",", ":")).encode() separators=(",", ":")).encode()
S.vehicles_gz = gzip.compress(S.vehicles, 5) S.vehicles_gz = gzip.compress(S.vehicles, 5)
S.count, S.computed_at = len(feats), time.time() S.count, S.computed_at = len(feats), time.time()
+21 -13
View File
@@ -123,10 +123,10 @@ for (const n of Object.keys(FILTERS)) document.getElementById("flt-" + n).onclic
// ---------- Züge ---------- // ---------- Züge ----------
// Server liefert je Zug die Position jetzt und alle `step` Sekunden danach (entlang der Strecke); // Server liefert je Zug die Gleispunkte der nächsten ~10 s, jeweils mit der Zeit, zu der der Zug dort ist;
// dazwischen wird gefahren, und die Spitze dreht sich weich in die jeweilige Richtung. // der Browser fährt sie ab, die Spitze dreht sich weich in die Richtung des aktuellen Gleisstücks.
let trains = { type: "FeatureCollection", features: [] }; let trains = { type: "FeatureCollection", features: [] };
let ts = 0, step = 2, clockOffset = 0; let ts = 0, clockOffset = 0;
let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat
@@ -140,19 +140,26 @@ async function refreshTrains() {
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.properties.bearing]));
for (const f of d.features) { for (const f of d.features) {
f.pts = [[...f.geometry.coordinates, f.properties.heading], ...(f.path || [])]; 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); const b = old.get(f.properties.id);
if (b !== undefined) f.properties.bearing = b; if (b !== undefined) f.properties.bearing = b;
} }
trains = d; trains = d;
ts = d.ts; ts = d.ts;
step = d.step || 2; render(); // sofort auf die aktuelle Position, nicht erst beim nächsten Bild
} catch (e) { /* nächster Versuch in ein paar Sekunden */ } } catch (e) { /* nächster Versuch in ein paar Sekunden */ }
} }
// kürzester Drehwinkel von a nach b in Grad (-180..180) // kürzester Drehwinkel von a nach b in Grad (-180..180)
const turn = (a, b) => ((((b - a) % 360) + 540) % 360) - 180; const turn = (a, b) => ((((b - a) % 360) + 540) % 360) - 180;
// Richtung von a nach b in Grad (0 = Norden), in Mercator wie die Karte; null wenn (fast) gleicher Punkt
function bearing(a, b) {
const y = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360));
const dx = ((b[0] - a[0]) * Math.PI) / 180, dy = y(b[1]) - y(a[1]);
return Math.hypot(dx, dy) < 1e-7 ? null : (Math.atan2(dx, dy) * 180) / Math.PI;
}
// SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit Rand zeichnen kann // SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit Rand zeichnen kann
const S = 64, R = 8; const S = 64, R = 8;
function sdfIcon(mask) { function sdfIcon(mask) {
@@ -209,21 +216,22 @@ map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
let lastRender = 0; let lastRender = 0;
function render() { function render() {
const t = Date.now() / 1000 - clockOffset - ts; 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
lastRender = performance.now(); lastRender = performance.now();
const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s
for (const f of trains.features) { for (const f of trains.features) {
const p = f.pts; const p = f.pts;
if (!p) continue; if (!p) continue;
const x = Math.max(0, t / step); // Gleispunkte mit Zeitstempel: Abschnitt suchen, in dem der Zug gerade ist, und darauf fahren
const i = Math.min(Math.floor(x), p.length - 1); let i = 0;
const a = p[i], b = p[Math.min(i + 1, p.length - 1)], k = i + 1 < p.length ? x - i : 0; 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;
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 kommt vom Server (Gleisverlauf an der Position); dazwischen interpolieren und weich nachdrehen // Richtung aus dem aktuellen Gleisstück; steht der Zug, die vom Server (Gleis am Halt)
const ha = a[2] ?? b[2], hb = b[2] ?? a[2]; const target = bearing(a, b) ?? f.properties.heading;
if (ha === null || ha === undefined) continue; if (target === null || target === undefined) continue;
const target = ha + turn(ha, hb) * k;
const cur = f.properties.bearing; const cur = f.properties.bearing;
f.properties.bearing = cur === undefined ? target : cur + turn(cur, target) * ease; f.properties.bearing = cur === undefined ? target : cur + turn(cur, target) * ease;
} }