Zum Inhalt springen

Feature: Wahlabend

Am 13. September 2026 wird der Rat der Stadt Oldenburg gewählt. Die Seite /wahlabend zeigt an diesem Abend den Auszählungsstand, die Sitze je Liste und Wahlbereich und — sobald die Personenstimmen vorliegen — wer nach dem Niedersächsischen Kommunalwahlgesetz gerade im Rat wäre. Die Zahlen sind die amtlichen Open-Data-Dateien der Stadt; gerechnet wird bei uns.

Der Bereich ist der ungewöhnlichste im ganzen Projekt: keine Datenbank, kein Cron, kein LLM. Er liest drei CSV-Dateien, rechnet und antwortet.

Die Stadt betreibt zur Wahl einen Votemanager (KDO) mit einer Ergebnispräsentation und einem Open-Data-Bereich. Drei Dateien tragen alles, was wir brauchen; sie haben denselben Kopf und unterscheiden sich nur im Zuschnitt des Gebiets:

Datei Zeilen
…-Stadtratswahl-Stadt.csv eine — die ganze Stadt
…-Stadtratswahl-Wahlbereiche.csv sechs — die Wahlbereiche I bis VI
…-Stadtratswahl-Wahlbezirk.csv 133 — die einzelnen Wahlbezirke (Briefwahlbezirke tragen 9xx)

Basis ist https://votemanager.kdo.de/20260913/03403000 mit der Präsentation unter /praesentation/ (Wahl-ID 913, Stadtratswahl) und den CSVs unter /daten/opendata/. Der Votemanager schickt kein CORS: Der Browser darf die Dateien nicht selbst holen, also holt das Backend sie und liefert ein fertiges Bild aus. Das ist ohnehin die richtige Arbeitsteilung — die Sitzzuteilung gehört nicht in den Browser.

Je Wahlvorschlag n stehen in jeder Zeile vier Größen. Das Schema hat sich zwischen 2021 und 2026 geändert; votemanager.parse erkennt am Kopf, welches vorliegt, und liest beide:

Bedeutung 2026 2021
Listenstimmen D<n>_1 D<n>_liste
Summe der Personenstimmen D<n>_3 D<n>_summe_kandidaten
Gesamt (Liste + Personen) D<n>_4 D<n>_summe_liste_kandidaten
Stimmen für Listenplatz k D<n>_2_<k> D<n>_<k>

Davor stehen A (Wahlberechtigte), B (Wähler*innen), C1/C2 (ungültige und gültige Stimmzettel), D (gültige Stimmen) sowie max-schnellmeldungen / anz-schnellmeldungen als Auszählungsstand des Gebiets. Ein Einzelwahlvorschlag hat nur D<n>_4.

Die CSV der Wahlbezirke sagt nicht, zu welchem Wahlbereich ein Bezirk gehört. Die Nummer sagt es: Urnenbezirke tragen den Wahlbereich als Hunderter (101…115 = I, 400…416 = IV), Briefwahlbezirke als Zehner hinter der 9 (910…916 = I, 960…966 = VI). area_of_district() setzt das um; gemessen ist die Regel an allen 133 Bezirken von 2021 — die Summen je Wahlbereich ergeben dort genau die Wahlbereichs-Zeilen (tests/test_wahlabend.py).

votemanager.fetch() hält ein Ergebnis 60 Sekunden — so lange cacht auch der Votemanager selbst, häufiger zu fragen bringt nichts. Scheitert der Abruf, bleibt der letzte gute Stand stehen und bekommt einen Fehlervermerk: source.ok = false und source.error reisen mit in die Antwort, die Zahlen bleiben die alten. Gab es noch nie einen guten Stand, ist die Antwort leer, aber wohlgeformt — Phase before, sechs Wahlbereiche, keine Sitze.

Seit der Belastungsprobe vom 07.09.2026 hat jede der drei Dateien ihr eigenes Gedächtnis: Scheitert eine (Netzfehler, 500, HTML-Fehlerseite mit Status 200, leerer Body, Kopfzeile ohne gebiet-name oder ohne eine einzige D<n>_-Spalte), bleibt für sie die letzte gute Fassung stehen, die anderen werden frisch übernommen; source.ok wird false, source.error nennt Datei und Grund. Zeitlimits: 5 s Verbindung, 10 s Lesen je Datei. Als vierte, optionale Datei liest crosscheck.py die Ergebnistabelle der Stadt-Ebene (JSON) und prüft die Reihenfolge der Listen gegen das Register — eine Abweichung steht als Warnung in notes („Spalte D7 heißt beim Votemanager ‚PIRATEN‘, im Register ‚Volt‘“). Dazu netzfrei die Kopfzeile: Kandidatenspalten je Liste gegen die Listenlängen des Registers. Notausgang ohne Deploy: WAHLABEND_COLUMNS (Slugs in Spaltenreihenfolge, .env, Neustart).

Ob die Open-Data-CSVs am Wahlabend genauso zügig gefüllt werden wie die Website der Stadt, weiß niemand. Deshalb gibt es seit dem 09.09.2026 einen zweiten Weg (presentation.py): Die Ergebnispräsentation unter /praesentation/ ist eine Vue-Anwendung, und alles, was sie zeigt, lädt sie aus JSON-Dateien unter /daten/api/wahl_913/. Genau die lesen wir — kein HTML, kein Browser.

Datei Inhalt
wahl.json menu_links: die Gebiets-Id der Stadt und die Ebene der Wahlbereiche
uebersicht_<ebene>_0.json eine Zeile je Wahlbereich mit Label und Gebiets-Id — vor der Auszählung leer (nur ein Zeitstempel)
ergebnis_<gebiet>_0.json je Liste drei Zeilen (Gesamt, Partei, Summe Kandidaten); die dritte trägt sub_zeilen mit jeder Bewerber*in in Listenreihenfolge; dazu Wahlberechtigte, Wähler*innen, Stimmzettel, gültige Stimmen und der Stand („22 von 22 Ergebnissen“)

Gemessen an der Ratswahl 2021 auf demselben Votemanager: Aus den sechs Wahlbereichs-JSONs entstehen dieselben Zeilen wie aus der CSV — je Liste, je Listenplatz dieselbe Zahl — und damit dieselben 50 Mandate (tests/test_wahlabend_praesentation.py, Fixtures unter tests/fixtures/wahlabend/praesentation-2021/).

Wann er greift: votemanager.fetch() sieht nach jedem CSV-Abruf nach, ob die Wahlbereichsdatei fehlt, einem Wahlbereich noch die Personenstimmen fehlen, oder er dort einfach noch nicht fertig ausgezählt ist. Nur dann holt er Übersicht, sechs Wahlbereiche und die Stadt (acht Abrufe) und übernimmt, was weiter ist: Maßgeblich ist zuerst der Auszählungsstand (mehr Schnellmeldungen als die CSV), erst bei Gleichstand entscheiden zusätzliche Personenstimmen. Das ist bewusst mehr als „hat die CSV Personen, oder nicht“ — eine Open-Data-CSV, die am Abend nicht mehr nachzieht, soll nicht für den Rest des Abends als „aktuell genug“ gelten, nur weil sie einmal für alle Bereiche Personenstimmen trug. Trägt die CSV überall Personenstimmen UND ist überall fertig ausgezählt, wird die Darstellung nicht gefragt. Die Wahlbezirke (133 Dateien) holt er nicht — die Hochrechnung hängt an der CSV; ohne sie gibt es Sitze und Namen, aber keine Prognose. Was übernommen wurde, steht als Hinweis in notes („I - Stadtmitte Nord, Stadt: Zahlen aus der Ergebnisdarstellung — sie ist weiter als die Open-Data-CSV.“); ein CSV-Ausfall bleibt daneben als source.error stehen, denn er ist einer.

Zwei Dinge unterscheiden die Quelle von der CSV, und beide sind der Grund, warum sie nur der Ersatz ist:

  • Die Listen stehen unter ihrem Namen, nicht unter einer Spaltennummer; eine Liste, die im Wahlbereich nicht antritt, fehlt dort einfach. Die Zuordnung läuft über dieselben Schlüsselwörter wie die Spaltenprobe (crosscheck.KEYWORDS). Passt ein Name zu keiner oder zu zwei Listen, wird der ganze Wahlbereich verworfen und gemeldet — eine fehlende Partei rechnete sich sonst still zu null Stimmen.
  • Die Bewerber*innen tragen keinen Listenplatz, nur ihre Position in der Reihenfolge. Platz k ist Zeile k.

Die Stadt-Ebene trägt nach der Auszählung außerdem Komponente.sitze — die Sitzverteilung, wie der Votemanager sie rechnet. Sie wird bei jedem Abruf mitgelesen (auch ohne Ersatzpfad, die Datei ist dieselbe wie bei der Spaltenprobe) und am Ende der Auszählung gegen die eigene Zuteilung gehalten: „Die Sitzverteilung des Votemanagers weicht von der eigenen Zuteilung ab: SPD 14 statt 15 — Zuordnung prüfen!“ Nur bei Phase complete — Zwischenstände holen beide zu verschiedenen Minuten.

Der Dienst selbst wirft nie: volles Bild → Bild ohne Hochrechnung und Abstände → letzter guter Stand mit Vermerk → leeres Bild mit Fehlertext. Jede Stufe steht in notes und im Log. Gleichzeitige erste Aufrufe warten auf EINEN Aufbau (threading.Condition), statt alle zu rechnen. Ein Fuzz-Test (tests/test_wahlabend_robust.py, 2.000 Zufallsstände) und ein falscher Votemanager im Test (tests/test_wahlabend_live.py, echter HTTP-Server mit 500ern, HTML, leeren Dateien, Zeitüberschreitung, vertauschten Spalten) halten das fest; tests/test_wahlabend_zahlen.py prüft Anteile, Wahlbeteiligung und Summen gegen die amtliche Präsentation 2021.

Die CSVs kennen nur Spaltennummern und Listenplätze, keine Namen. Die stehen in der amtlichen Bekanntmachung der zugelassenen Wahlvorschläge (Wahlausschuss, 23.07.2026). kommunalwahl/kandidaten.py liest das PDF und schreibt kommunalwahl/kandidaten.json: je Wahlbereich und Wahlvorschlag die Bewerber*innen mit Listenplatz, Name, Beruf, Jahrgang und Wohnort — 16 Wahlvorschläge, 383 Bewerber*innen, 52 zu vergebende Sitze.

Gelesen wird über die Koordinaten, nicht über den Text: Die Textfassung setzt Name und Beruf in eine Zeile, und wo das eine aufhört, steht dort nirgends. Im PDF stehen die Felder in Spalten, und visitor_text von pypdf liefert zu jedem Textstück seine Position — die Spaltenkanten stehen als Konstanten oben in kandidaten.py.

Die Farben je Liste kommen aus kommunalwahl/parteien-meta.json; eine Liste ohne Eintrag bekommt einen neutralen Grauton. register.load() ist lru_cache-gehalten — die Datei wird einmal je Prozess gelesen.

election/seats.py rechnet dreimal Hare/Niemeyer, in dieser Reihenfolge:

  1. Die Sitze des Rates auf die Wahlvorschläge nach ihren Gesamtstimmen im ganzen Wahlgebiet (§ 37 Abs. 2 i. V. m. § 36 Abs. 2 und 3).
  2. Die Sitze jeder Partei auf ihre Wahlbereichslisten nach den dort erzielten Stimmen (§ 37 Abs. 3). Es gibt keine feste Sitzzahl je Wahlbereich — wo eine Partei stark ist, holt sie dort mehr Sitze.
  3. Die Sitze einer Wahlbereichsliste auf Liste und Personen, im Verhältnis Listenstimmen zu Personenstimmen (§ 36 Abs. 4). Personensitze gehen nach höchster Stimmenzahl (Abs. 5), Listensitze nach Listenreihenfolge an die noch nicht Gewählten (Abs. 6).

Dazu zwei Sonderfälle, die selten sind und trotzdem vorkommen: Bekommt eine Liste mehr Sitze, als sie Bewerber*innen hat, wandern die überzähligen zu den stimmenstärksten nicht gewählten Bewerber*innen derselben Partei in anderen Wahlbereichen (§ 37 Abs. 5, in der Antwort als kind: "transfer"). Ein Einzelwahlvorschlag hat keine anderen Wahlbereiche — sein zweiter Sitz bliebe unbesetzt (§ 36 Abs. 7) und steht dann als Hinweis in notes.

Verifiziert gegen das amtliche Ergebnis der Ratswahl 2021: alle 50 Mandate, einschließlich der Unterscheidung „direkt“ gegen „über die Liste“ (tests/test_wahlabend.py). Das ist der einzige belastbare Beweis, den es vor dem Wahlabend gibt — dieselben Eingaben, dasselbe Ergebnis wie der Wahlausschuss.

Die Zuteilung ist schnell genug, um sie oft zu wiederholen — und genau das tut der Bereich, um die interessantere Frage zu beantworten:

  • votes_to_seat je Kandidatur — die kleinste Zahl zusätzlicher Personenstimmen, mit der diese Person einen Sitz hätte, alles andere unverändert. Binärsuche über die vollständige Zuteilung, Suchgrenze 30.000 Stimmen; 0 heißt „schon drin“, null „außer Reichweite“.
  • votes_to_next_seat / votes_to_lose_seat je Liste — dasselbe auf Stufe 1, gedacht als Listenstimmen im stärksten Wahlbereich der Liste, Suchgrenze 80.000.

Diese Abstände sind der Grund, warum das Zusammensetzen einer Antwort rund zwei Sekunden dauert — und damit der Grund für den Cache im nächsten Abschnitt.

Kein Modell mit Anspruch, sondern das, was man am Wahlabend im Kopf tut: Für jeden noch offenen Wahlbezirk sein Ergebnis von 2021 nehmen und mit dem Trend skalieren, den die schon ausgezählten Bezirke desselben Wahlbereichs für diese Liste zeigen (Stimmen 2026 / Stimmen 2021 über die ausgezählten Bezirke).

Das geht nur, weil die Wahlbezirke 2026 genauso geschnitten und nummeriert sind wie 2021 — gleiche Zahl, gleiche Wahllokale. Die Referenz liegt als drei CSVs von 2021 im Repo (kommunalwahl/referenz-2021/, altes Spaltenschema) und wird über ratswahl-2021.json auf die Listenindizes von 2026 umgeschlüsselt; Listen ohne Nachfolger 2026 fallen dabei weg.

Die Randfälle:

  • Eine Liste ohne 2021er Vergleich (BSW, PGM, …) bekommt ihren bisherigen Stimmenanteil auf die geschätzten gültigen Stimmen der offenen Bezirke.
  • Im Wahlbereich noch nichts ausgezählt → es gilt der stadtweite Trend.
  • Stadtweit noch nichts ausgezählt → es gibt keine Hochrechnung.
  • Ein ausgezählter Bezirk ohne Gegenstück von 2021 wird gezählt und als Hinweis in notes genannt — kein Fehler, nur eine Lücke in der Basis.

Aus den hochgerechneten Stimmen läuft dieselbe Zuteilung noch einmal; ihr Ergebnis steht getrennt in projected_seats und projected_mandates. Die Seite hält beides auseinander: „drin“ ist ausgezählt, „Hochrechnung: drin“ ist gerechnet, und wo sich beide widersprechen, sagt sie das („drin · direkt · Hochrechnung: raus“).

GET /api/wahlabend — öffentlich (die Zahlen sind es auch), ohne Konto, hinter dem Feature-Schalter. Zwei Query-Parameter:

Parameter Wirkung
probe=2021 Generalprobe: die Zahlen von 2021 im Register von 2026
counted=N nur für die Generalprobe — nur die ersten N Wahlbezirke gelten als ausgezählt (0…500)

Die Antwortform ist ElectionNight in web/backend/app/antworten.py und damit Teil des API-Vertrags; das Frontend leitet seine Typen daraus ab (lib/wahlabend.ts). Die tragenden Felder:

Feld Inhalt
dataset live oder probe
phase before (nichts ausgezählt) · counting · complete
person_votes_available ob irgendwo schon Personenstimmen vorliegen
source fetched_at, last_modified (Stand beim Votemanager), ok, error
progress Schnellmeldungen erwartet und eingegangen
parties je Liste: Stimmen, Anteil, Sitze, Hochrechnung, 2021er Vergleich, Abstände
areas je Wahlbereich: Stand, Summen und je Liste ihre Bewerber*innen mit Personenstimmen
mandates / projected_mandates wer einen Sitz hat, mit kind: direct · list · transfer · unknown
notes Menschentext: Losfälle, unbesetzte Sitze, fehlende Personenstimmen

Weil das Zusammensetzen rund zwei Sekunden dauert, wird das fertige Bild gehalten und nach Ablauf im Hintergrund erneuert, während die alte Antwort weiter ausgeliefert wird (Stale-while-revalidate). Darunter liegt der 60-Sekunden-Cache des CSV-Abrufs. Die Generalprobe wird je counted-Wert genau einmal gerechnet und dann behalten — ihre Zahlen ändern sich ja nicht.

Beide Caches leben im Prozess: Ein Neustart des Dienstes setzt sie zurück, ein zweiter Worker hätte seine eigenen.

Die Wahl der Oberbürgermeisterin/des Oberbürgermeisters läuft am selben Tag, hat aber keine Open-Data-CSV — gemessen am 11.09.2026: acht probierte Namensmuster, alle 404, auch im Archiv von 2021. election/mayor.py liest die Zahlen deshalb ausschließlich aus der Ergebnisdarstellung (derselbe JSON-Ersatzpfad wie oben, andere Wahl-Id, eine flache Tabelle: jede Zeile ist eine Kandidatur, nicht drei wie bei der Ratswahl). Die neun Kandidaturen selbst kommen aus kommunalwahl/wahl-fakten.json, nicht aus einer zweiten Handschrift; ein Slug wird aus dem Nachnamen abgeleitet (slug_of). Die Antwortform ist MayorNight: phase (before/counting/complete), Wahlbeteiligung, gültige/ungültige Stimmen, je Kandidatur Stimmen und Anteil, und runoff — die beiden Slugs einer Stichwahl, sobald der Votemanager sie meldet. Hängt am Schalter wahlabend (nicht tippspiel) — sie ist Teil des Wahlabends, auch wenn das Tippspiel sie für seinen OB-Vergleich mitliest. Dieselbe probe(counted)-Generalprobe wie bei der Ratswahl, mit der Fixture tests/fixtures/wahlabend/ob-2021.json.

Die Seite liegt wie /kommunalwahl und /changelog außerhalb von app/(app)/ — kein Konto-Gate, eigener Kopf. Alles Gerechnete kommt fertig vom Backend; lib/wahlabend.ts enthält nur Anzeige-Logik (Formate, Sortierung, der Status einer Kandidatur in Worten).

Der Aufbau folgt der Reihenfolge, in der man am Wahlabend fragt: Wie weit ist die Auszählung (Anzeigetafel) → wie stehen die Listen (Tafel mit Balken, Vergleich zu 2021, Sitzband) → und dann für eine gewählte Liste: wer ist in welchem Wahlbereich drin, wer ist knapp dran. Die gewählte Liste steht in der URL (?liste=<slug>) und im localStorage, überlebt also das Neuladen.

Abgefragt wird einmal je Minute (refetchInterval), passend zum Cache des Votemanagers. Drei Zustände ohne Zahlen sind ausdrücklich gestaltet: Schalter aus, Abruf gescheitert, Phase before vor der ersten Meldung.

Vier Bausteine kamen am 06.09.2026 nach Tims Wunsch dazu; alle rechnen mit den Zahlen, die der Endpunkt ohnehin liefert:

  • Halbkreis der Sitze (components/wahlabend/halbkreis.tsx): ein Punkt je Sitz in Listenfarbe (Designsprache: Parteifarben nur als Punkte, nie als Flächen), Listen in Stimmzettel-Reihenfolge von links nach rechts, die Mehrheitslinie in der Mitte. Während der Auszählung stehen Stand und Hochrechnung nebeneinander. Die Geometrie (halbkreis() in lib/wahlabend.ts) verteilt die Plätze auf drei Reihen im Verhältnis ihres Umfangs; das Bild zum Teilen (s. u.) rechnet dieselbe Geometrie in Python nach.
  • Mehrheiten im Rat (mehrheiten.tsx): Listen antippen, die Leiste zeigt die Summe gegen die Marke „Mehrheit ab 27“ (mehrheit() = mehr als die Hälfte der 52 Sitze). Darunter alle minimalen Mehrheitsbündnisse bis drei Partner (koalitionen()): Gruppen, aus denen kein Partner entfallen könnte, ohne die Mehrheit zu verlieren. Rein rechnerisch, ohne Aussage, wer mit wem will; die 53. Stimme der Oberbürgermeisterin bzw. des Oberbürgermeisters steht als Hinweis dabei.
  • Gewinne und Verluste: Punkte gegenüber 2021 als Balken um die Nulllinie, Deltas in Signal-Orange.
  • Der Verlauf des Abends (verlauf.tsx): zwei Treppenlinien über die Uhrzeit — der Anteil der gewählten Liste und die Zahl der ausgezählten Wahlbezirke — mit einer gemeinsamen Ableseleiste aus dem Grafik-Baukasten. Treppe (curveStepAfter), weil sich zwischen zwei Meldungen nichts ändert. Die Punkte liefert das Feld history des Endpunkts.
  • Kandidatenrennen: In jeder Wahlbereich-Karte trägt jede Kandidatur einen Balken relativ zur stärksten Person der Liste; die Marke in Signal-Orange ist die Sitzgrenze — die Stimmen des schwächsten Personensitzes (sitzgrenze()), nur wo es einen gibt. Ein Wahlbereich, der gerade neue Bezirke gemeldet hat, leuchtet kurz auf; Zahlen gleiten statt zu springen (lib/use-tween.ts, höchstens 300 ms, beim ersten Rendern sofort).

web/backend/app/election/history.py hält die Minutenstände des Abends: ein Punkt je Änderung (ausgezählte Bezirke, Anteile oder Sitze anders als zuvor), höchstens 1.440, als JSON-Datei unter data/wahlabend-verlauf.json (Umgebungsvariable WAHLABEND_HISTORY_FILE), atomar geschrieben, damit ein Deploy am Wahlabend den Verlauf nicht löscht. Ein unschreibbarer Pfad wird geloggt und stört den Endpunkt nicht. Die Generalprobe erzeugt sich eine synthetische Reihe (Stände bei 10, 20, … Bezirken, ab 18 Uhr im Viertelstundentakt) und schreibt nichts in die Datei.

GET /api/wahlabend/bild.png liefert den Stand als PNG in 1200 × 630 — die Größe, die Messenger und soziale Netze als Vorschau erwarten: Kicker, Titel, Stand-Zeile, Halbkreis mit Mehrheitslinie, Legende, Quelle. Query wie beim JSON-Endpunkt (probe, counted) plus feld (seats oder projected_seats; ohne Angabe während der Auszählung die Hochrechnung, sonst der Stand). Gerendert mit Pillow in election/image.py, in doppelter Größe und mit LANCZOS verkleinert; Schriften aus ios/Resources/Fonts/, mit Rückfall auf die Pillow-Schrift, damit das Bild nie an einer Schrift scheitert. 60 Sekunden Cache je Stand. Hinter demselben Schalter wie die Seite; die Seite verlinkt es in der Anzeigetafel als „Bild zum Teilen“.

Ob die Personenstimmen mit ausgezählt werden oder zunächst nur die Summen je Liste, ist vorab nicht sicher zu sagen. Bei der Kommunalwahl wird jede Stimme einer Person gegeben; das aufzuschlüsseln ist der aufwendige Teil der Auszählung, und die Schnellmeldung kann sich zunächst auf die Summen je Liste beschränken.

Der Bereich muss das nicht wissen — er erkennt es am CSV: Liegen weder Listenstimmen noch Stimmen je Listenplatz vor, setzt person_votes_available auf false. Dann liefert die Seite Sitze je Liste und Wahlbereich, aber keine Namen: Die Mandate tragen kind: "unknown", und in notes steht der Satz dazu. Kommen die Personenstimmen später, füllen sich Namen und Abstände von selbst — ohne Deploy, ohne Umschalten.

1. Den Schalter setzen. In der .env auf dem Server, dann den Dienst neu starten:

Terminal-Fenster
FEATURE_FLAGS=wahlabend

Der Schalter kommt über /api/app-config auch im Frontend an — ein Neubau ist dafür nicht nötig, ein Neustart des Backends schon.

2. Die Generalprobe ansehen. Vor dem Ernstfall dieselbe Seite mit echten Zahlen, nur eben denen von 2021:

/wahlabend?probe=2021&counted=60 # 60 der 133 Wahlbezirke ausgezählt
/wahlabend?probe=2021 # alles ausgezählt: das Endergebnis 2021

counted zählt die Bezirke in der Reihenfolge der CSV, nicht in der, in der sie am Wahlabend melden würden — es geht um die Phase, nicht um eine Simulation des Abends. Mit einem mittleren Wert lässt sich prüfen, was die Hochrechnung tut; mit dem vollen Lauf, ob die Zuteilung das amtliche Ergebnis trifft.

3. Was bei einem Netzfehler passiert. Die Seite zeigt den letzten guten Stand mit Fehlervermerk — nicht eine leere Seite und nicht Nullen. Der Zeitstempel im Kopf ist der des Standes, nicht der des Aufrufs, die Anzeige altert also sichtbar. Erst wenn der Dienst seit dem Start noch nie erfolgreich abgerufen hat, gibt es nichts zu zeigen.

4. Der Takt. Drei 60-Sekunden-Stufen liegen hintereinander: der Cache des Votemanagers, unser Bild, die Abfrage im Browser. Ein frisch gemeldeter Wahlbezirk braucht entsprechend ein paar Minuten bis auf den Schirm — das ist so gewollt und steht der Anzeige nicht im Weg, weil sie ihren eigenen Stand ausweist.

Die Stichwahl (/wahlabend/stichwahl) läuft seit 23.09.2026 schneller: Backend (mayor.TTL_LIVE) und Browser fragen ab Wahlschluss alle 15 Sekunden, auch im Hintergrund-Tab (der Fenstertitel trägt den Stand). Die erste Stufe bleibt — das CDN vor dem Votemanager hält jede Datei bis zu 60 s (cache-control: max-age=60). Aus bis zu drei Minuten werden so höchstens anderthalb. Am CDN vorbei zu fragen ginge technisch, wäre am Wahlabend der Stadt gegenüber aber unhöflich.

Dazu kamen am selben Tag: recent_districts in der Antwort (die jüngsten gemeldeten Bezirke; der Verlauf merkt sich je Stand new_districts, damit der Ticker auch nach einem Neustart stimmt), die Aufholrechnung in der Hochrechnung (trailing, needed_share_pct — Arithmetik auf dem Modell: x = (N + Rückstand) / 2N über die erwarteten offenen Stimmen N) und GET /api/wahlabend/stichwahl/bild.png?format=beitrag|story|quer (election/runoff_image.py), das die Seite auch als og:image nennt.

5. Den Votemanager-Pfad übersteuern. Falls die Stadt eine andere Adresse oder Wahl-ID benutzt als erwartet, muss dafür kein Code geändert werden:

Terminal-Fenster
WAHLABEND_VOTEMANAGER_URL=https://votemanager.kdo.de/<datum>/<gemeinde>

Darunter werden /praesentation/ und die drei Dateien unter /daten/opendata/ angehängt. Nach der Änderung den Dienst neu starten (die Caches liegen im Prozess).

6. Danach. Steht das amtliche Endergebnis fest (Wahlausschuss, voraussichtlich in der Woche nach dem 13.09.2026), ist die Seite ein Rückblick und braucht den Schalter nicht mehr — er kann aus der .env und samt seinem Registry-Eintrag aus dem Code. Genau das steht als fertig_wenn in kern/features.py.

Reihenfolge, die am Wahlabend trägt — jede Zeile ist ohne Deploy machbar:

  1. Samstag: auf dev oder feature FEATURE_FLAGS=wahlabend setzen, Dienst neu starten, /wahlabend öffnen: Phase before, source.ok true, kein Eintrag in notes. Dann die Generalprobe /wahlabend?probe=2021&counted=60&liste=volt und das Bild /api/wahlabend/bild.png?probe=2021&counted=60 ansehen.
  2. Sonntag vor 18 Uhr auf Prod: Schalter setzen, systemctl restart nwz-web-api, danach curl -s https://ratslotse.de/api/wahlabend | head -c 400 — "phase": "before", "ok": true. Die Seite zeigt „Noch nichts ausgezählt“, Landing und Heute tragen die Einstiege.
  3. Ab der ersten Meldung: notes lesen. Steht dort eine Warnung zur Spaltenreihenfolge (die Prüfung gegen das JSON des Votemanagers, s. o.), greift der Notausgang WAHLABEND_COLUMNS (Slugs in Spaltenreihenfolge in der .env, Neustart) — die Kandidatenlisten hängen am Slug und bleiben richtig. Steht dort „Personenstimmen liegen noch nicht vor“, ist das kein Fehler: Sitze ja, Namen erst mit den Personenstimmen.
  4. Wenn der Votemanager wegbleibt: Die Seite zeigt den letzten Stand mit Vermerk; source.error nennt die Datei. Ändert die Stadt Pfade, hilft WAHLABEND_VOTEMANAGER_URL (Basis bis vor /daten/…). Läuft nichts mehr, journalctl -u nwz-web-api -n 200 — jede ausgefallene Stufe steht dort mit Traceback, im Fehler-Panel des Admin-Bereichs ebenfalls.
  5. Nach dem amtlichen Endergebnis: Schalter raus, Einstiege verschwinden, /wahlabend zeigt den Hinweis; data/wahlabend-verlauf.json bleibt als Protokoll des Abends liegen.
Pfad Inhalt
web/backend/app/election/votemanager.py Abruf und Parser der drei CSVs, beide Spaltenschemata, 60-s-Cache, Nummernregel Bezirk → Bereich
web/backend/app/election/presentation.py Der Ersatzpfad: die JSON-Dateien der Ergebnisdarstellung (Übersicht, Wahlbereiche, Stadt, Sitzverteilung)
web/backend/app/election/crosscheck.py Spaltenprobe: Listenreihenfolge und Kopfzeile gegen das Register
web/backend/app/election/register.py Kandidatenregister aus kandidaten.json + Farben
web/backend/app/election/seats.py Hare/Niemeyer und die Zuteilung nach §§ 36, 37 NKWG, dazu die Abstandsrechnungen
web/backend/app/election/reference.py Die Ratswahl 2021 als Vergleich und Basis der Hochrechnung
web/backend/app/election/projection.py Die Hochrechnung der offenen Wahlbezirke
web/backend/app/election/service.py Zusammensetzen der Antwort, Generalprobe, Stale-while-revalidate
web/backend/app/routers/wahlabend.py GET /api/wahlabend hinter dem Feature-Schalter
web/backend/app/antworten.py Antwortform ElectionNight (Teil des API-Vertrags)
web/frontend/app/wahlabend/ Die Seite, außerhalb des Konto-Gates
web/frontend/components/wahlabend/view.tsx Anzeigetafel, Listen-Tafel, Sitzband, Wahlbereiche, Mandate
web/frontend/lib/wahlabend.ts Formate, Sortierung, Status einer Kandidatur in Worten
kommunalwahl/kandidaten.py Erzeugt kandidaten.json aus der amtlichen Bekanntmachung (pypdf, Koordinaten)
kommunalwahl/kandidaten.json 16 Wahlvorschläge, 383 Bewerber*innen, 6 Wahlbereiche, 52 Sitze
kommunalwahl/referenz-2021/ Die drei Open-Data-CSVs von 2021 und die amtliche Sitzverteilung
tests/test_wahlabend.py Zuteilung gegen 2021, Register gegen die CSV-Köpfe, Parser, Hochrechnung, Endpunkt
tests/test_wahlabend_praesentation.py Der Ersatzpfad gegen die echten JSONs von 2021: dieselben Zeilen wie die CSV, dieselben 50 Mandate
tests/fixtures/wahlabend/praesentation-2021/ wahl.json, Übersicht, sechs Wahlbereiche und die Stadt der Ratswahl 2021, unverändert vom Votemanager

Der Bereich hat keine Datenbank, an der man etwas nachsehen könnte — die Tests sind deshalb die Dokumentation der Rechnung:

  • Hare/Niemeyer gegen das Rechenbeispiel „Musterstadt“ aus dem Erklärblatt des Wahlamts Braunschweig, dazu Losfall und Mehrheitsklausel.
  • Die Zuteilung reproduziert das amtliche Ergebnis 2021 — alle 50 Mandate mit ihrer Art.
  • Überhang wandert in andere Wahlbereiche (§ 37 Abs. 5).
  • Ohne Personenstimmen gibt es Sitze, aber keine Namen.
  • Das Register passt zu den CSV-Spalten 2026, und die eingecheckte kandidaten.json ist der Stand des Skripts (--pruefen), nicht von Hand nachbearbeitet.
  • Die Nummernregel trifft alle 133 Wahlbezirke.
  • Die Hochrechnung trifft das Endergebnis, wenn die Referenz die Wahrheit ist — und liefert nichts, wenn noch nichts ausgezählt ist.
  • Der Endpunkt ist ohne Schalter ein 404 und übersteht einen Netzfehler.