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.
Woher die Zahlen kommen
Abschnitt betitelt „Woher die Zahlen kommen“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.
Das Spaltenschema
Abschnitt betitelt „Das Spaltenschema“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.
Wahlbezirk → Wahlbereich
Abschnitt betitelt „Wahlbezirk → Wahlbereich“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).
Abruf und Cache
Abschnitt betitelt „Abruf und Cache“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.
Was der Abruf abfängt
Abschnitt betitelt „Was der Abruf abfängt“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).
Der Ersatzpfad: die Ergebnisdarstellung
Abschnitt betitelt „Der Ersatzpfad: die Ergebnisdarstellung“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
kist Zeilek.
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.
Das Kandidatenregister
Abschnitt betitelt „Das Kandidatenregister“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.
Die Sitzzuteilung (NKWG §§ 36, 37)
Abschnitt betitelt „Die Sitzzuteilung (NKWG §§ 36, 37)“election/seats.py rechnet dreimal Hare/Niemeyer, in dieser Reihenfolge:
- 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).
- 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.
- 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.
Abstände: „Wie viele Stimmen fehlen?“
Abschnitt betitelt „Abstände: „Wie viele Stimmen fehlen?““Die Zuteilung ist schnell genug, um sie oft zu wiederholen — und genau das tut der Bereich, um die interessantere Frage zu beantworten:
votes_to_seatje 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;0heißt „schon drin“,null„außer Reichweite“.votes_to_next_seat/votes_to_lose_seatje 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.
Die Hochrechnung
Abschnitt betitelt „Die Hochrechnung“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
notesgenannt — 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“).
Der Endpunkt
Abschnitt betitelt „Der Endpunkt“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 |
Zwei Cache-Stufen
Abschnitt betitelt „Zwei Cache-Stufen“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 OB-Wahl (GET /api/wahlabend/ob)
Abschnitt betitelt „Die OB-Wahl (GET /api/wahlabend/ob)“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 /wahlabend
Abschnitt betitelt „Die Seite /wahlabend“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.
Halbkreis, Mehrheiten, Verlauf, Rennen
Abschnitt betitelt „Halbkreis, Mehrheiten, Verlauf, Rennen“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()inlib/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 Feldhistorydes 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).
Der Verlauf (history)
Abschnitt betitelt „Der Verlauf (history)“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.
Das Bild zum Teilen (bild.png)
Abschnitt betitelt „Das Bild zum Teilen (bild.png)“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“.
Was am Wahlsonntag noch offen ist
Abschnitt betitelt „Was am Wahlsonntag noch offen ist“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.
Am Wahlabend
Abschnitt betitelt „Am Wahlabend“1. Den Schalter setzen. In der .env auf dem Server, dann den Dienst neu
starten:
FEATURE_FLAGS=wahlabendDer 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 2021counted 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:
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.
Checkliste für den 13.09.2026
Abschnitt betitelt „Checkliste für den 13.09.2026“Reihenfolge, die am Wahlabend trägt — jede Zeile ist ohne Deploy machbar:
- Samstag: auf
devoderfeatureFEATURE_FLAGS=wahlabendsetzen, Dienst neu starten,/wahlabendöffnen: Phasebefore,source.oktrue, kein Eintrag innotes. Dann die Generalprobe/wahlabend?probe=2021&counted=60&liste=voltund das Bild/api/wahlabend/bild.png?probe=2021&counted=60ansehen. - Sonntag vor 18 Uhr auf Prod: Schalter setzen,
systemctl restart nwz-web-api, danachcurl -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. - Ab der ersten Meldung:
noteslesen. Steht dort eine Warnung zur Spaltenreihenfolge (die Prüfung gegen das JSON des Votemanagers, s. o.), greift der NotausgangWAHLABEND_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. - Wenn der Votemanager wegbleibt: Die Seite zeigt den letzten Stand mit
Vermerk;
source.errornennt die Datei. Ändert die Stadt Pfade, hilftWAHLABEND_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. - Nach dem amtlichen Endergebnis: Schalter raus, Einstiege verschwinden,
/wahlabendzeigt den Hinweis;data/wahlabend-verlauf.jsonbleibt als Protokoll des Abends liegen.
Dateien
Abschnitt betitelt „Dateien“| 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 |
Was die Tests halten
Abschnitt betitelt „Was die Tests halten“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.jsonist 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.