Streckensammler
Punktewertung · Whitepaper

Streckensammler Point System

Dieses Whitepaper beschreibt deterministisch, wie jeder deutsche Bahnhof seinen Punktewert bekommt. Der Algorithmus ist reproduzierbar — zwei Läufe auf identischen Eingaben liefern byteidentische Ergebnisse.

Dieses Dokument beschreibt, wie jeder deutsche Bahnhof einen Punktewert zugewiesen bekommt, der widerspiegelt, wie schwer er zu erreichen ist. Ziel ist, dass das Abhaken entlegener Sackgassen-Endpunkte (Heimbach Eifel, Sylt-Westerland, Wangerooge) sich lohnend anfühlt, während große Knotenpunkte (Frankfurt Hbf, Köln Hbf) niedrig scoren.

Der Algorithmus arbeitet auf der Vereinigung aller Eisenbahn-Infrastrukturdaten (DB InfraGO + OSM-Imports) und ist vollständig deterministisch — zwei Läufe auf derselben Eingabe erzeugen byteidentische Ausgaben.

1. Eingaben

Sei

S={s1,s2,},R={r1,r2,}\mathcal{S} = \{s_1, s_2, \dots\}, \qquad \mathcal{R} = \{r_1, r_2, \dots\}

die Menge der Stationen (Betriebsstellen, Schlüssel ist Ril100/DS100) bzw. Strecken (VzG-nummerierte Linien), geladen aus data/sources/db.json und data/sources/osm.json.

Jede Strecke rr trägt eine geordnete Liste von Stops

stops(r)=((s1,k1),(s2,k2),,(snr,knr)),\mathrm{stops}(r) = \big( (s_1, k_1), (s_2, k_2), \dots, (s_{n_r}, k_{n_r}) \big),

wobei kik_i die Kilometrierung entlang der Strecke ist. Strecken-Overrides (extra_stops, removed_stops aus strecke_overrides.yaml) werden vor dem Algorithmus angewandt, damit nutzerseitig korrigierte Topologie sichtbar wird. Das Flag exclude:true wird hier ignoriert — exkludierte Strecken sind weiterhin reale physische Verbindungen und müssen für die Konnektivität im Graphen bleiben. Ihre Exklusion betrifft nur den Export.

2. Aufbau des Graphen

Wir bauen einen ungerichteten gewichteten Graphen G=(S,E,w)G = (\mathcal{S}, E, w) wie folgt.

2.1 Kanten aus Strecken-Stops

Für jede Strecke rr und jedes aufeinanderfolgende Paar (si,ki),(si+1,ki+1)(s_i, k_i), (s_{i+1}, k_{i+1}) in stops(r)\mathrm{stops}(r) fügen wir die Kante {si,si+1}\{s_i, s_{i+1}\} mit Gewicht

w({si,si+1})=ki+1kiw(\{s_i, s_{i+1}\}) = |k_{i+1} - k_i|

hinzu. Mehrfachkanten (dasselbe Paar auf mehreren Strecken) werden auf das Minimum der km zusammengefasst — konservativ und deterministisch.

2.2 Geh-Kanten

Fern- und Regio-Bahnhof an derselben Stelle tragen oft unterschiedliche Ril100-Codes (z.B. FMT Montabaur Fernbahn vs. FMTN Montabaur Regio, 21m\sim 21\,\mathrm{m} auseinander). Wir fügen eine synthetische Kante

w({sa,sb})=wwalkw(\{s_a, s_b\}) = w_{\text{walk}}

für jedes Paar von Personenverkehrs-Stationen sa,sbs_a, s_b ein, deren Großkreisdistanz unter einer Schwelle liegt:

dgeo(sa,sb)dwalk-max.d_{\text{geo}}(s_a, s_b) \le d_{\text{walk-max}}.

Defaults: wwalk=0.25kmw_{\text{walk}} = 0.25\,\mathrm{km}, dwalk-max=100md_{\text{walk-max}} = 100\,\mathrm{m}. Nicht-Personenverkehrs-Knoten (Abzw, Üst, Stw) sind ausgenommen — an einem Stellwerk steigt man nicht in einen anderen Zug um. Streckenmitgliedschaften fusionierter Stationen werden vereinigt, sodass ein co-lokalisiertes ICE/Regio-Paar oberhalb der Hub-Schwelle landen und zu einem echten Hub werden kann.

2.3 Connector-Klassifikation

Eine Strecke rr ist ein Connector, wenn jeder ihrer Stops auch auf einer anderen Strecke vorkommt:

connector(r)    sstops(r):rr, sstops(r).\text{connector}(r) \iff \forall s \in \mathrm{stops}(r) : \exists r' \ne r,\ s \in \mathrm{stops}(r').

Connector-Kanten gehen in GG ein (Konnektivität bleibt erhalten), aber sie zählen nicht für den Hub-Grad HH (Definition unten). Das verhindert, dass kurze 2-Stop-Verbindungs-VzGs (typisches Beispiel: 6089 Birkenwerder–Hohen Neuendorf) ihre Endpunkte fälschlich zu Hubs befördern.

3. Topologische Klassifikation

3.1 Hub-Grad

Für jede Station ss definieren wir

H(s)={rR:r ist kein Connector und sstops(r)}.H(s) = \big| \{ r \in \mathcal{R} : r \text{ ist kein Connector und } s \in \mathrm{stops}(r) \} \big|.

Eine Station ist genau dann ein echter Hub, wenn H(s)hthreshH(s) \ge h_{\text{thresh}} (Default hthresh=3h_{\text{thresh}} = 3).

Sei Hreal={s:H(s)hthresh}\mathcal{H}_{\text{real}} = \{ s : H(s) \ge h_{\text{thresh}} \}.

3.2 Mesh-Core

Der Mesh-Core MS\mathcal{M} \subseteq \mathcal{S} ist der größte induzierte Teilgraph von GG, in dem jeder Knoten Grad 2\ge 2 hat. Wir berechnen ihn durch iteratives Pruning:

  1. Start mit MS\mathcal{M} \leftarrow \mathcal{S}.
  2. Solange sM\exists s \in \mathcal{M} mit degM(s)1\deg_{\mathcal{M}}(s) \le 1: alle solchen ss gleichzeitig entfernen.

Übrig bleiben die zyklusdichten Teile des Netzes — jede „echte Hauptbahn” liegt in M\mathcal{M}, kein Astende und keine Stichstrecke.

3.3 Tips (Sackgassenenden)

Eine Station ss ist ein Tip, wenn sie außerhalb des Mesh-Core liegt und genau einen Nachbarn in GG hat:

T={sM:degG(s)=1}.\mathcal{T} = \{ s \notin \mathcal{M} : \deg_G(s) = 1 \}.

Tips sind die tatsächlichen Sackgassen-Endpunkte: Heimbach (Eifel), Westerland (Sylt), Bayerisch Eisenstein. Geh-Kanten sind bereits in GG enthalten, sodass eine zu Fuß von einem Hub erreichbare Station kein Tip mehr ist — das ist beabsichtigt.

3.4 Pseudo-Hubs und der Insel-Bonus

Manche Zusammenhangskomponenten von GG enthalten gar keinen echten Hub (echte Inselstrecken wie Wangerooge, plus eine lange Schwanzverteilung von Datenartefakten). Für jede hub-lose Komponente CC synthetisieren wir einen Pseudo-Hub über Closeness-Centrality auf CC:

cent(v)=1uCdG(v,u),pseudo(C)=argmaxvCcent(v).\mathrm{cent}(v) = \frac{1}{\sum_{u \in C} d_G(v, u)}, \qquad \mathrm{pseudo}(C) = \arg\max_{v \in C} \mathrm{cent}(v).

Gleichstand wird über H()H(\cdot), dann Graph-Grad, dann alphabetische Stations-ID aufgelöst (volle Determinismus).

Fehlt CC auch ein Mesh-Core-Knoten, übernimmt derselbe Pseudo-Hub die Rolle für M\mathcal{M}. Die Komponenten sind klein (üblicherweise <50< 50 Knoten); das hier nötige all-pairs-Dijkstra ist also trivial.

Zwei Korrekturen werden auf kleine, tatsächlich isolierte Komponenten angewandt (mit npax-minPersonenverkehrsstationen in Cnpax-maxn_{\text{pax-min}} \le |\text{Personenverkehrsstationen in } C| \le n_{\text{pax-max}}, Defaults 2 und 8):

  • Hub-Penalty-Verzicht. Pseudo-Hubs sind synthetische Referenzpunkte, keine echten Mehrlinien-Knoten — daher entfällt die Hub-Penalty (§4.4) für sie.
  • D-Bonus. Jede Station in CC erhält
D(s)D(s)+δislandD(s) \leftarrow D(s) + \delta_{\text{island}}

zu ihrer Hub-Distanz hinzuaddiert. Damit wird modelliert, dass die Komponente als Ganzes vom Hauptnetz abgekoppelt ist. Default δisland=150km\delta_{\text{island}} = 150\,\mathrm{km}.

Der Größenfilter schließt offensichtliche Datenartefakte aus: Einzel-Station-Fragmente (Pritzwalk West, Kahl Privatb) bekommen keinen Bonus, große isolierte Cluster (Heideexpress mit 28 Stops, Hessencourier mit 11) sind ebenfalls ausgenommen — entweder Museumsbahnen oder Fehlimporte, keine echten entlegenen Netze.

Wir schreiben Heff=Hreal{Pseudo-Hubs}\mathcal{H}_{\text{eff}} = \mathcal{H}_{\text{real}} \cup \{\text{Pseudo-Hubs}\}, Meff=M{Pseudo-Mesh-Cores}\mathcal{M}_{\text{eff}} = \mathcal{M} \cup \{\text{Pseudo-Mesh-Cores}\}.

4. Distanz-Terme

Alle drei werden über Multi-Source-Dijkstra auf GG mit dem jeweiligen Quell-Set berechnet:

Bcore(s)=minmMeffdG(s,m),T(s)=mintTdG(s,t),D(s)=minhHeffdG(s,h)+δisland1[s in kleiner Inselkomponente].\begin{aligned} B^{\text{core}}(s) &= \min_{m \in \mathcal{M}_{\text{eff}}} d_G(s, m), \\ T(s) &= \min_{t \in \mathcal{T}} d_G(s, t), \\ D(s) &= \min_{h \in \mathcal{H}_{\text{eff}}} d_G(s, h) + \delta_{\text{island}} \cdot \mathbb{1}[s \text{ in kleiner Inselkomponente}]. \end{aligned}

4.1 Branch-Term B(s)B(s)

Für Tips verwenden wir die rohe Mesh-Core-Distanz; für alle anderen deckeln wir bei der Tip-Distanz:

B(s)={Bcore(s)falls sT,min(Bcore(s), T(s))sonst.B(s) = \begin{cases} B^{\text{core}}(s) & \text{falls } s \in \mathcal{T}, \\[2pt] \min\big(B^{\text{core}}(s),\ T(s)\big) & \text{sonst}. \end{cases}

Der Nicht-Tip-Fall ist die Mid-Branch-Dämpfungsregel: eine Station 2 km vor einem Tip auf einer 45-km-Stichstrecke sollte über ihre Tip-Nähe bewertet werden, nicht über die aufgeblähte Mesh-Core-Distanz. Ohne diese Regel würde jeder Mid-Stop einer langen Stichstrecke auf 100 hochgehen (Rügen vor dem Fix hatte 4 Stationen bei 100, davon 1 echter Tip und 3 Mid-Stops).

4.2 Hub-Term D(s)D(s)

Direkt oben definiert; der Insel-Bonus kommt während der Komponentenverarbeitung dazu (§3.4).

5. Score-Formel

Für jede Personenverkehrsstation ss (also is_passenger_art(s) = true und betrieb \notin {Planung, ehemals}) berechnen wir

raw(s)=β+wBB(s)+wTB(s)1[sT]+wDD(s)wHmax(0, H(s)1)1[sPseudo-Hubs]\boxed{ \mathrm{raw}(s) = \beta + w_B \sqrt{B(s)} + w_T \sqrt{B(s)} \cdot \mathbb{1}[s \in \mathcal{T}] + w_D \sqrt{D(s)} - w_H \cdot \max(0,\ H(s) - 1) \cdot \mathbb{1}[s \notin \text{Pseudo-Hubs}] }

und clampen:

points(s)=clamp(round(raw(s)), pmin, pmax).\mathrm{points}(s) = \mathrm{clamp}\big( \mathrm{round}(\mathrm{raw}(s)),\ p_{\min},\ p_{\max} \big).

Die fünf Terme im Klartext:

TermDefault-GewichtEffekt
β\beta — Basis5Untergrenze für jede einbezogene Station.
wBB(s)w_B \sqrt{B(s)} — BranchwB=10w_B = 10Distanz zum Mesh-Core, sqrt-gedämpft (eine 100-km-Stichstrecke ist 2×\sqrt{2}\times so wertvoll wie 50 km, nicht 2×2\times).
wTB(s)w_T \sqrt{B(s)} — Tip-BonuswT=5w_T = 5Verdoppelt den Branch-Beitrag für echte Sackgassen-Endpunkte. Lange Tip-Strecken erreichen 100; kurze Mid-Stops bleiben moderat.
wDD(s)w_D \sqrt{D(s)} — Hub-DistanzwD=5w_D = 5Distanz zum nächstgelegenen echten oder Pseudo-Hub.
wHmax(0,H(s)1)-w_H \max(0, H(s)-1) — Hub-PenaltywH=3w_H = 3Hauptbahn-Knoten sind leicht zu erreichen. Entfällt bei Pseudo-Hubs (synthetisch, kein echter „Knoten”-Charakter).

Default-Clamp: pmin=1p_{\min} = 1, pmax=100p_{\max} = 100.

5.1 Warum \sqrt{\cdot} und nicht linear

Lineare Distanz-Terme belohnen lange Stichstrecken unbeschränkt und brauchen einen harten Clamp, um die Scores in [1,100][1, 100] zu halten. Wurzeldämpfung ist selbstregulierend: wer die km-Distanz verdoppelt, multipliziert den Term nur um 2\sqrt{2}. Das komprimiert das obere Ende, ohne alle Differenzierung zu verlieren — ein 50-km-Tip und ein 30-km-Tip unterscheiden sich vor dem Clamp immer noch um 10(5030)1610\,(\sqrt{50} - \sqrt{30}) \approx 16 Punkte.

5.2 Warum H1H - 1 in der Penalty

Eine Station auf einer einzigen Strecke (H=1H = 1) ist per Definition kein Knoten; erst ab H2H \ge 2 greift die Penalty. Echte Hubs (Hhthresh=3H \ge h_{\text{thresh}} = 3) sammeln dadurch spürbare Penalty-Punkte. Pseudo-Hubs sind ausgenommen, weil sie auf kleinen isolierten Komponenten typischerweise H=2H = 2 haben und die Penalty die zentralste Station einer Inselstrecke unfair bestrafen würde.

6. Determinismus

Jeder Schritt ist über Läufe und Plattformen hinweg byte-deterministisch:

  • Adjazenzlisten werden in sorted()-Reihenfolge aufgebaut.
  • Pruning verarbeitet Grad-1-Kandidaten pro Runde in sorted()-Reihenfolge.
  • Dijkstra-Heap-Items sind (distance, station_id, node) — bei Float-Gleichstand entscheidet die alphabetische ID.
  • Mehrfachkanten-Kollaps wählt das km-Minimum über Stable Sort auf (km, strecke_id).
  • math.sqrt ist IEEE-754-korrekt gerundet.
  • round() verwendet Pythons Banker’s Rounding.

Zwei Neuberechnungen auf identischen Eingaben liefern identisches YAML.

7. Persistenz und Stale-Detection

Das Ergebnis wird nach data/station_points.yaml geschrieben, sortiert nach station_id für stabile Git-Diffs:

generated_at: "2026-05-05T16:14:29Z"
source_hashes:
  db: "<sha256 of db.json>"
  osm: "<sha256 of osm.json, or empty>"
  config: "<sha256 of points_config.yaml>"
  strecke_overrides: "<sha256 of strecke_overrides.yaml>"
points:
  AAB: 8
  AA: 12
  KHEI: 87
  ...

Stale-Check zur Export-Zeit. Vor dem Export der v2-Mobile-App-JSON berechnet der Server alle vier Source-Hashes neu und vergleicht sie mit dem YAML. Jede Abweichung ⇒ HTTP 409, „Punkte sind veraltet — bitte vor Export neu berechnen”. Damit ist garantiert, dass exportierte points-Werte aus einer Berechnung stammen, die die aktuellen Daten + Overrides gesehen hat.

Die Neuberechnung ist nutzergetrieben: ein „Punkte neu berechnen”-Button im Export-Panel des Viewers oder python main.py recalc-points auf der Kommandozeile. Beide rufen denselben Code-Pfad auf (points.calculate(data, config)).

8. Konfiguration

data/points_config.yaml ist verpflichtend — es gibt keinen Default-Fallback im Code. Fehlende Datei ⇒ Neuberechnung scheitert mit HTTP 400.

base: 5
weights:
  branch: 10
  tip: 5
  distance: 5
  hub_penalty: 3
hub_threshold: 3
clamp:
  min: 1
  max: 100
walking_edge:
  max_meters: 100
  edge_km: 0.25
island:
  d_bonus_km: 150
  min_size: 2
  max_size: 8

Alle Werte gehen in den Source-Hash ein, eine Änderung kippt also den Export-Status auf stale.

9. Triage isolierter Komponenten

Der Viewer enthält eine „Insel-Triage”-Seite, die jede Zusammenhangskomponente von GG ohne echten Hub auflistet. Jede Karte zeigt die Stationen, Strecken und Personenverkehrszahl der Komponente sowie ob der Insel-Bonus aktiv ist (also ob der Größenfilter die Komponente akzeptiert).

Zwei Fehlermuster, die die Seite aufdecken soll:

  • Fehlender Endpunkt-Stop. Die häufigste Ursache einer falschen isolierten Komponente: eine Strecke heißt Heide–Büsum, listet aber erst ab Tiebensee — Heide selbst fehlt. Ein Strecken-Override (extra_stops) mit dem fehlenden Stop verbindet die Komponente wieder.
  • Doppelte Stations-Codes. Manchmal hat dieselbe physische Verzweigung zwei Ril100-Codes (z.B. WRV Rövershagen und WRVE Rövershagen Karls Erlebnisdorf, 2 km auseinander auf verschiedenen Strecken). Den Personenverkehrs-Code als extra_stop auf der Hauptstrecken-VzG zu ergänzen, repariert die Topologie. Die Geh-Kanten-Logik findet diese Fälle nicht, weil die geographische Distanz die Schwelle überschreitet.

Große isolierte Komponenten (> island.max_size Personenverkehrsstationen) sind typischerweise OHE-/Heideexpress-artige Museumsdaten und werden absichtlich nicht gepusht — egal wie der Nutzer sie interpretieren möchte; eine Punkte-Inflation über 28 Stationen eines Museumsclusters würde die Gesamtverteilung verzerren.