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
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 trägt eine geordnete Liste von Stops
wobei 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 wie folgt.
2.1 Kanten aus Strecken-Stops
Für jede Strecke und jedes aufeinanderfolgende Paar in fügen wir die Kante mit Gewicht
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,
auseinander). Wir fügen eine synthetische Kante
für jedes Paar von Personenverkehrs-Stationen ein, deren Großkreisdistanz unter einer Schwelle liegt:
Defaults: , . 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 ist ein Connector, wenn jeder ihrer Stops auch auf einer anderen Strecke vorkommt:
Connector-Kanten gehen in ein (Konnektivität bleibt erhalten), aber sie zählen nicht für den Hub-Grad (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 definieren wir
Eine Station ist genau dann ein echter Hub, wenn (Default ).
Sei .
3.2 Mesh-Core
Der Mesh-Core ist der größte induzierte Teilgraph von , in dem jeder Knoten Grad hat. Wir berechnen ihn durch iteratives Pruning:
- Start mit .
- Solange mit : alle solchen gleichzeitig entfernen.
Übrig bleiben die zyklusdichten Teile des Netzes — jede „echte Hauptbahn” liegt in , kein Astende und keine Stichstrecke.
3.3 Tips (Sackgassenenden)
Eine Station ist ein Tip, wenn sie außerhalb des Mesh-Core liegt und genau einen Nachbarn in hat:
Tips sind die tatsächlichen Sackgassen-Endpunkte: Heimbach (Eifel), Westerland (Sylt), Bayerisch Eisenstein. Geh-Kanten sind bereits in 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 enthalten gar keinen echten Hub (echte Inselstrecken wie Wangerooge, plus eine lange Schwanzverteilung von Datenartefakten). Für jede hub-lose Komponente synthetisieren wir einen Pseudo-Hub über Closeness-Centrality auf :
Gleichstand wird über , dann Graph-Grad, dann alphabetische Stations-ID aufgelöst (volle Determinismus).
Fehlt auch ein Mesh-Core-Knoten, übernimmt derselbe Pseudo-Hub die Rolle für . Die Komponenten sind klein (üblicherweise Knoten); das hier nötige all-pairs-Dijkstra ist also trivial.
Zwei Korrekturen werden auf kleine, tatsächlich isolierte Komponenten angewandt (mit , 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 erhält
zu ihrer Hub-Distanz hinzuaddiert. Damit wird modelliert, dass die Komponente als Ganzes vom Hauptnetz abgekoppelt ist. Default .
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 , .
4. Distanz-Terme
Alle drei werden über Multi-Source-Dijkstra auf mit dem jeweiligen Quell-Set berechnet:
4.1 Branch-Term
Für Tips verwenden wir die rohe Mesh-Core-Distanz; für alle anderen deckeln wir bei der Tip-Distanz:
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
Direkt oben definiert; der Insel-Bonus kommt während der Komponentenverarbeitung dazu (§3.4).
5. Score-Formel
Für jede Personenverkehrsstation (also is_passenger_art(s) = true
und betrieb {Planung, ehemals}) berechnen wir
und clampen:
Die fünf Terme im Klartext:
| Term | Default-Gewicht | Effekt |
|---|---|---|
| — Basis | 5 | Untergrenze für jede einbezogene Station. |
| — Branch | Distanz zum Mesh-Core, sqrt-gedämpft (eine 100-km-Stichstrecke ist so wertvoll wie 50 km, nicht ). | |
| — Tip-Bonus | Verdoppelt den Branch-Beitrag für echte Sackgassen-Endpunkte. Lange Tip-Strecken erreichen 100; kurze Mid-Stops bleiben moderat. | |
| — Hub-Distanz | Distanz zum nächstgelegenen echten oder Pseudo-Hub. | |
| — Hub-Penalty | Hauptbahn-Knoten sind leicht zu erreichen. Entfällt bei Pseudo-Hubs (synthetisch, kein echter „Knoten”-Charakter). |
Default-Clamp: , .
5.1 Warum und nicht linear
Lineare Distanz-Terme belohnen lange Stichstrecken unbeschränkt und brauchen einen harten Clamp, um die Scores in zu halten. Wurzeldämpfung ist selbstregulierend: wer die km-Distanz verdoppelt, multipliziert den Term nur um . 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 Punkte.
5.2 Warum in der Penalty
Eine Station auf einer einzigen Strecke () ist per Definition kein Knoten; erst ab greift die Penalty. Echte Hubs () sammeln dadurch spürbare Penalty-Punkte. Pseudo-Hubs sind ausgenommen, weil sie auf kleinen isolierten Komponenten typischerweise 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.sqrtist 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 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.
WRVRövershagen undWRVERövershagen Karls Erlebnisdorf, 2 km auseinander auf verschiedenen Strecken). Den Personenverkehrs-Code alsextra_stopauf 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.