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
+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
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:
def __init__(self, path: Path, today: dt.date | None = None):
self.path = path
@@ -227,6 +269,23 @@ class Feed:
self._geom_cache[key] = 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
def point_at(g, d: float):
pts, cum, _ = g
@@ -299,14 +358,16 @@ class Feed:
arr, dep = self.times(ti, ds, r[1] if r else None)
if not (dep[0] <= t <= arr[-1]):
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)
sd = g[2]
o = self.off[ti]
if t >= arr[i]: # steht am Halt i
dist, delay = sd[i], dep[i] - self.st_dep[o + i]
if t >= pa[i]: # steht am Halt 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:
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]
lon, lat = self.point_at(g, dist)
# 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]],
"heading": heading,
},
"_ti": ti, "_dist": dist, # intern, für track(); werden vor dem Versand entfernt
})
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
# 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)
merged.unlink()
tmp.rename(rail)
# IDs durchnummerieren: osmium extract braucht sonst ~4 GB RAM für die weit verstreuten OSM-IDs
subprocess.run([C.OSMIUM, "renumber", str(tmp), "-o", str(merged), "--overwrite"], check=True)
tmp.unlink()
merged.rename(rail)
urls_file.write_text(wanted)
for p in parts:
Path(p).unlink()
+16 -15
View File
@@ -19,7 +19,7 @@ from . import border, realtime
from .feed import TZ, Feed
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"
@@ -121,23 +121,24 @@ def positions(src: Source, t: float) -> list:
def compute_positions():
now = time.time()
feats, future = [], [{} for _ in range(STEPS)]
feats = []
for src in list(S.sources.values()):
if not src.feed:
continue
feats += positions(src, now)
for k in range(STEPS):
future[k].update({f"{src.name}/{f['properties']['id']}": f["geometry"]["coordinates"] + [f["properties"]["heading"]]
for f in src.feed.vehicles(now + STEP * (k + 1), src.rt)})
for f in feats:
path = []
for pos in future:
p = pos.get(f["properties"]["id"])
if p is None:
break # Fahrt endet
path.append(p)
f["path"] = path
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "step": STEP, "features": feats},
cur = positions(src, now)
# Distanz entlang der Strecke alle STEP s; daraus der Gleisverlauf, dem der Browser folgt
future = [{f["properties"]["id"]: f["_dist"] 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 cur:
key, dists = f["properties"]["id"].split("/", 1)[1], [f["_dist"]]
for pos in future:
if key not in pos:
break # Fahrt endet
dists.append(pos[key])
f["path"] = src.feed.track(f.pop("_ti"), dists, STEP)
del f["_dist"]
feats += cur
S.vehicles = json.dumps({"type": "FeatureCollection", "ts": now, "features": feats},
separators=(",", ":")).encode()
S.vehicles_gz = gzip.compress(S.vehicles, 5)
S.count, S.computed_at = len(feats), time.time()