From a064fccb2387c48a4d06b3821b0ad53ec91c5c85 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 20:45:12 +0200 Subject: [PATCH] Richtung aus dem Gleisverlauf statt aus der Bewegung, sanftes Bremsen/Anfahren an Halten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server berechnet den Kurs aus der Strecke (±25 m) an der Zugposition; stimmt dadurch auch im Stillstand (vorher blieb der Pfeil verdreht stehen) - Trapezprofil: ~150 m vor/nach jedem Halt bremst bzw. beschleunigt der Zug, Ankunfts- und Abfahrtszeiten bleiben gleich Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb --- tests/test_core.py | 10 ++++++++++ trainpulse/feed.py | 28 ++++++++++++++++++++++++++-- trainpulse/server.py | 3 ++- web/app.js | 19 ++++++++----------- 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index d731f0f..959f2aa 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -94,6 +94,16 @@ def test_all(): lon, lat = v[0]["geometry"]["coordinates"] assert lat < 47.0 and abs(lon - 8.05) < 0.01, (lon, lat) # halbe Fahrzeit = Umweg-Scheitelpunkt assert v[0]["properties"]["next"] == "Beta" + 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 + + # Anfahren/Bremsen: symmetrisch, am Anfang/Ende langsamer als linear, Gesamtstrecke stimmt + from trainpulse.feed import travelled + assert travelled(8000, 600, 0) == 0 and abs(travelled(8000, 600, 600) - 8000) < 1e-6 + assert abs(travelled(8000, 600, 300) - 4000) < 1e-6 + assert travelled(8000, 600, 10) < 8000 * 10 / 600 / 2 + assert abs(travelled(8000, 600, 590) - (8000 - travelled(8000, 600, 10))) < 1e-6 + assert all(travelled(200, 60, x) <= travelled(200, 60, x + 1) for x in range(60)) # kurz: nie rückwärts assert f.vehicles(at(8, 11), {})[0]["geometry"]["coordinates"] == [8.1, 47.0] # steht in Beta ver, rt = realtime.parse(rt_feed(delay_b=300), f.trip_index) diff --git a/trainpulse/feed.py b/trainpulse/feed.py index de8dec5..6f596ab 100644 --- a/trainpulse/feed.py +++ b/trainpulse/feed.py @@ -38,6 +38,25 @@ def xy(lon, lat, lat0): # lokale Projektion in Metern, reicht für Abstände en return lon * 111320 * math.cos(math.radians(lat0)), lat * 110540 +BRAKE_M = 150 # so viele Meter vor/nach einem Halt bremst bzw. beschleunigt der Zug + + +def travelled(length: float, duration: float, t: float) -> float: + """Zurückgelegte Meter auf einem Abschnitt: sanft anfahren, konstant fahren, sanft bremsen + (trapezförmiges Geschwindigkeitsprofil), statt mit voller Geschwindigkeit in den Halt zu fahren.""" + if length <= 0 or duration <= 0: + return 0.0 + t = max(0.0, min(t, duration)) + ta = min(duration / 2, 2 * BRAKE_M * duration / (length + 2 * BRAKE_M)) # Zeit fürs Anfahren/Bremsen + vmax = length / (duration - ta) + a = vmax / ta + if t < ta: + return 0.5 * a * t * t + if t > duration - ta: + return length - 0.5 * a * (duration - t) ** 2 + return 0.5 * a * ta * ta + vmax * (t - ta) + + class Feed: def __init__(self, path: Path, today: dt.date | None = None): self.path = path @@ -240,9 +259,13 @@ class Feed: if t >= arr[i]: # steht am Halt i dist, delay = sd[i], dep[i] - self.st_dep[o + i] else: - f = (t - dep[i - 1]) / max(1, arr[i] - dep[i - 1]) - dist, delay = sd[i - 1] + f * (sd[i] - sd[i - 1]), arr[i] - self.st_arr[o + i] + dist = sd[i - 1] + travelled(sd[i] - sd[i - 1], arr[i] - dep[i - 1], t - dep[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 + (x1, y1), (x2, y2) = self.point_at(g, dist - 25), self.point_at(g, dist + 25) + dx, dy = (x2 - x1) * math.cos(math.radians(lat)), y2 - y1 + heading = round(math.degrees(math.atan2(dx, dy)), 1) if dx or dy else None route = self.routes[self.trip_route[ti]] feats.append({ "type": "Feature", @@ -251,6 +274,7 @@ class Feed: "id": f"{dstr}/{ti}", "line": route["line"], "cat": route["cat"], "nr": self.trip_number[ti], "to": self.trip_headsign[ti], "delay": delay if r else None, "next": self.stop_name[self.st_stop[o + i]], + "heading": heading, }, }) return feats diff --git a/trainpulse/server.py b/trainpulse/server.py index f129058..418b6cf 100644 --- a/trainpulse/server.py +++ b/trainpulse/server.py @@ -84,7 +84,8 @@ def compute_positions(): return now = time.time() feats = S.feed.vehicles(now, S.rt) - future = [{f["properties"]["id"]: f["geometry"]["coordinates"] for f in S.feed.vehicles(now + STEP * k, S.rt)} + future = [{f["properties"]["id"]: f["geometry"]["coordinates"] + [f["properties"]["heading"]] + for f in S.feed.vehicles(now + STEP * k, S.rt)} for k in range(1, STEPS + 1)] for f in feats: path = [] diff --git a/web/app.js b/web/app.js index d2c468a..694204c 100644 --- a/web/app.js +++ b/web/app.js @@ -122,7 +122,7 @@ async function refreshTrains() { if (now) clockOffset = Date.now() / 1000 - now; const old = new Map(trains.features.map((f) => [f.properties.id, f.properties.bearing])); for (const f of d.features) { - f.pts = [f.geometry.coordinates, ...(f.path || [])]; + f.pts = [[...f.geometry.coordinates, f.properties.heading], ...(f.path || [])]; const b = old.get(f.properties.id); if (b !== undefined) f.properties.bearing = b; } @@ -132,13 +132,8 @@ async function refreshTrains() { } catch (e) { /* nächster Versuch in ein paar Sekunden */ } } -// Fahrtrichtung in Grad (0 = Norden, im Uhrzeigersinn), in Mercator gerechnet wie die Karte -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]); - if (Math.hypot(dx, dy) < 3e-7) return null; // unter ~2 m: steht (Rundungsrauschen würde die Spitze umklappen) - return (Math.atan2(dx, dy) * 180) / Math.PI; -} +// kürzester Drehwinkel von a nach b in Grad (-180..180) +const turn = (a, b) => ((((b - a) % 360) + 540) % 360) - 180; // SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit Rand zeichnen kann const S = 64, R = 8; @@ -207,10 +202,12 @@ function render() { const i = Math.min(Math.floor(x), p.length - 1); const a = p[i], b = p[Math.min(i + 1, p.length - 1)], k = i + 1 < p.length ? x - i : 0; f.geometry.coordinates = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]; - const target = bearing(a, b); - if (target === null) continue; // steht: Richtung behalten + // Richtung kommt vom Server (Gleisverlauf an der Position); dazwischen interpolieren und weich nachdrehen + const ha = a[2] ?? b[2], hb = b[2] ?? a[2]; + if (ha === null || ha === undefined) continue; + const target = ha + turn(ha, hb) * k; const cur = f.properties.bearing; - f.properties.bearing = cur === undefined ? target : cur + (((((target - cur) % 360) + 540) % 360) - 180) * ease; + f.properties.bearing = cur === undefined ? target : cur + turn(cur, target) * ease; } map.getSource("trains").setData(trains); }