"""Kippfaelle Kreistag, Stufe 1: War der letzte Sitz knapper als die fehlenden Stimmen des Kreises? Andere Methodik als kippfaelle.py (Raete): Ein Kreistag wird in mehreren Wahlbereichen gewaehlt, die Sitze auf die Listen verteilt aber der ganze Kreis (§ 37 NKWG, Hare/Niemeyer auf die Kreissummen). Grundlage sind deshalb die amtlichen Kreissummen und die amtliche Sitzverteilung aus der Kreis-Instanz von votemanager (000), nicht die Bezirksdateien. Die Fehlmenge ist die Summe der Kreistagsbezirke mit Urteil "Stimmen fehlen" aus zettelcheck.csv. Je Kreis: 1. Sitzverteilung nachrechnen und mit der amtlichen vergleichen. 2. Knappheit: wie viele Stimmen haette jede Liste allein mehr gebraucht, um einen Sitz zu gewinnen? 3. Vergleich mit der Fehlmenge: Nur wo der Abstand kleiner ist, kann sich rechnerisch etwas aendern. 4. Zur Einordnung: welchen Anteil der fehlenden Stimmen braeuchte die Liste mindestens, wenn sich der Rest wie in den betroffenen Bezirken verteilt? Verglichen mit ihrem Anteil in diesen Bezirken. docker run --rm -v "$PWD":/work -u $(id -u):$(id -g) -w /work kw-ebenen \\ python nds/out/kippfaelle_kreistag.py """ import csv import json import re import sys import tempfile import time from fractions import Fraction from pathlib import Path import requests STAND = sys.argv[1] if len(sys.argv) > 1 else "2026-09-25_1001" ROOT = Path("nds/data/stand") / STAND OUT = Path("nds/out/stand") / STAND CACHE = OUT / "kippfaelle_kreistag_api" CACHE.mkdir(parents=True, exist_ok=True) S = requests.Session() S.headers["User-Agent"] = "zettelcheck-analyse (f.a.w.ostmann+zettelcheck@gmail.com)" try: import certifi _ca = tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) _ca.write(Path(certifi.where()).read_text()) for _p in sorted(Path("nds/certs").glob("*.pem")): _ca.write("\n" + _p.read_text()) _ca.close() S.verify = _ca.name except ImportError: pass KREISE = {r["kreis"]: r["landkreis"] for r in csv.DictReader(open(OUT / "wirkung_kreise.csv", encoding="utf-8"))} \ if (OUT / "wirkung_kreise.csv").exists() else {} GEM = {r["ags"]: r["gemeinde"] for r in csv.DictReader(open("nds/out/gemeinden.csv", encoding="utf-8"))} def api(url): fn = CACHE / re.sub(r"[^A-Za-z0-9_.-]", "_", url)[-180:] if fn.exists(): return json.loads(fn.read_text()) time.sleep(0.3) r = S.get(url, timeout=30) r.raise_for_status() fn.write_text(r.text) return r.json() def hare(votes: dict, seats: int): """Hare/Niemeyer mit exakten Bruechen und Mehrheitsklausel (§ 36 Abs. 2 und 3 NKWG).""" G = sum(votes.values()) q = {k: Fraction(v * seats, G) for k, v in votes.items()} z = {k: int(x) for k, x in q.items()} rest = {k: q[k] - z[k] for k in q} offen = seats - sum(z.values()) order = sorted(rest, key=lambda k: -rest[k]) z1 = dict(z) for k in order[:offen]: z1[k] += 1 maj = [k for k, v in votes.items() if 2 * v > G] if maj and 2 * z1[maj[0]] <= seats: z[maj[0]] += 1 order = [k for k in order if k != maj[0]] for k in order[:offen - 1]: z[k] += 1 return z, q return z1, q manifest = {} with open(ROOT / "manifest.csv", encoding="utf-8") as f: for r in csv.DictReader(f): manifest.setdefault(r["ags"], r["url"].split("/daten/opendata/")[0]) # Fehlmengen je Kreis (Kreistag, Urteil "Stimmen fehlen" inkl. Tendenz) fehl = {} for r in csv.DictReader(open(OUT / "zettelcheck.csv", encoding="utf-8")): if not re.search(r"kreis|regionsversammlung", r["wahl"], re.I): continue kreis = r["ags"][:5] fehl.setdefault(kreis, []) if "Stimmen fehlen" in r["urteil"]: fehl[kreis].append(dict(ags=r["ags"], gemeinde=GEM.get(r["ags"], r["ags"]), nr=r["gebiet-nr"], name=r["gebiet-name"], fehlend=round(float(r["fehlend_ggue_median"])), urteil=r["urteil"])) def bewerte(s, r): """Noetiger Anteil an den fehlenden Stimmen (s) gegen den Anteil in den betroffenen Bezirken (r). Gleiche Grenzen wie bei den Raeten: realistisch <= 1,75-fach und <= 10 Punkte, denkbar <= 2,5-fach und <= 15 Punkte.""" if s <= r: return "schon bei gleichmäßiger Ergänzung" f = s / r if r > 0 else float("inf") d = s - r if f <= 1.75 and d <= 0.10: return "realistisch" if f <= 2.5 and d <= 0.15: return "denkbar" return "unrealistisch" def fold(x): return re.sub(r"[^a-z0-9]", "", x.lower().replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss")) def bezirk_stimmen(b): """Stimmen je Liste (langer Name) im Kreistagsbezirk b aus der Bezirksdatei der Gemeinde.""" d = ROOT / b["ags"] od = json.loads((d / "open_data.json").read_text(encoding="utf-8")) if (d / "open_data.json").exists() else {} for f in sorted(d.glob("Open-Data-*Wahlbezirk*.csv")): with open(f, encoding="utf-8-sig") as fh: rows = list(csv.DictReader(fh, delimiter=";")) if not rows or not re.search(r"kreis|regionsversammlung", rows[0].get("wahl", ""), re.I) \ or re.search(r"landr|stich|präsident", rows[0].get("wahl", ""), re.I): continue wahl = rows[0]["wahl"].strip() namen = {} # Wahlname in open_data.json kann vom CSV-Namen abweichen -> auch ueber den Dateinamen zuordnen alias = {wahl} | {c.get("wahl") for c in od.get("csvs", []) if c.get("url") == f.name} eintraege = [e for e in od.get("dateifelder", []) if e.get("name") in alias] if not eintraege: # Wahl inzwischen kuerzer benannt: eindeutiger Praefix eintraege = [e for e in od.get("dateifelder", []) if e.get("name") and fold(wahl).startswith(fold(e["name"]))] eintraege = eintraege if len(eintraege) == 1 else [] for e in eintraege: namen = {int(re.sub(r"\D", "", x["feld"])): x["wert"] for x in e.get("parteien", [])} for r in rows: if (r.get("gebiet-nr") or "").strip() == b["nr"].strip(): out = {} for c, v in r.items(): m = re.match(r"D(\d+)_4$", c or "") if m and (v or "").strip(): out[namen.get(int(m.group(1)), c)] = int(float(v)) return out return None ergebnisse, ohne = [], [] for od in sorted(ROOT.glob("*000/open_data.json")): ags = od.parent.name d = json.loads(od.read_text(encoding="utf-8")) kand = [x for x in d.get("ergebnisgrafiken", []) if re.search(r"kreis|regionsversammlung", x["wahl"], re.I) and not re.search(r"landr|stich|präsident", x["wahl"], re.I)] if not kand or ags not in manifest: continue x = kand[0] base = manifest[ags] url = f"{base}/daten/api/wahl_{x['wahl_id']}/ergebnis_{x['link']['id']}_{x['stimmentyp']}.json" K = api(url)["Komponente"] sz = K["sitze"]["tortenDiagramm"] seats = int(re.search(r"(\d+)\s+Sitze", sz["footer"]).group(1)) hinweis = " ".join(K.get("info", {}).get("hinweis", [])) balken = K["grafik"]["balken"] + K["grafik"].get("sonstigeBalken", []) # Listen ueber den langen Namen zusammenfuehren (Kurznamen sind in Grafik und Torte nicht immer gleich) lang = {b["bezeichnung"]: b.get("bezeichnungAusfuehrlich") or b["bezeichnung"] for b in balken} votes = {b["bezeichnung"]: int(b["wert"]) for b in balken} amt_lang = {e.get("tooltip") or e["label"]: e["sitze"] for e in sz["entries"]} amt = {p: amt_lang.get(lang[p], amt_lang.get(p, 0)) for p in votes} z, q = hare(votes, seats) ok = z == amt and sum(amt.values()) == sum(amt_lang.values()) ganz = {p: int(q[p]) for p in votes} restsitze = sorted(votes, key=lambda p: -(q[p] - ganz[p])) n_rest = seats - sum(ganz.values()) reste = [dict(partei=p, rest=float(q[p] - ganz[p]), quote=float(q[p]), sitze=z[p], restsitz=i < n_rest) for i, p in enumerate(restsitze)] # Knappheit: Stimmen, die eine Liste allein mehr gebraucht haette G = sum(votes.values()) knapp = [] for p in votes: def mit(xtra): v = dict(votes) v[p] += xtra return hare(v, seats)[0] lo, hi = 0, max(50, G // 10) if mit(hi)[p] <= z[p]: continue while hi - lo > 1: m = (lo + hi) // 2 if mit(m)[p] > z[p]: hi = m else: lo = m z2 = mit(hi) knapp.append(dict(partei=p, stimmen=hi, auf_kosten=[k for k in votes if z2[k] < z[k]])) knapp.sort(key=lambda k: k["stimmen"]) bez = fehl.get(ags[:5], []) M = sum(b["fehlend"] for b in bez) # Stimmen je Liste in den betroffenen Bezirken (nur dort koennen die fehlenden Stimmen hingehen; # Listen, die in diesem Wahlbereich nicht antraten, bekommen dort nichts) kurz_von = {fold(lang[p]): p for p in votes} kurz_von.update({fold(p): p for p in votes}) in_bez = {p: 0 for p in votes} ohne_daten = 0 for b in bez: st = bezirk_stimmen(b) if not st: ohne_daten += 1 continue for name, v in st.items(): p = kurz_von.get(fold(name)) if p is None: p = next((q for q in votes if fold(q) and fold(q) in fold(name)), None) if p is None and name.startswith("Einzel"): # Einzelwahlvorschlag: ueber den Nachnamen nach = fold(name.split()[-1]) p = next((q for q in votes if nach in fold(q) or nach in fold(lang[q])), None) if p is not None: in_bez[p] += v else: print(" ?? Liste nicht zugeordnet:", ags, name) Gb = sum(in_bez.values()) # Einordnung: Anteil der Fehlmenge, den die Liste mindestens braeuchte, wenn sich der Rest so # verteilt wie in den betroffenen Bezirken for k in knapp: p = k["partei"] k["kreisanteil"] = votes[p] / G k["bezirksanteil"] = in_bez[p] / Gb if Gb else None k["moeglich"] = M >= k["stimmen"] k["ohne_stimmen_im_bezirk"] = bool(Gb) and in_bez[p] == 0 if k["ohne_stimmen_im_bezirk"]: k["moeglich"] = False # trat in den betroffenen Bezirken nicht an k["noetiger_anteil"] = None if not k["moeglich"]: continue rest_g = (Gb - in_bez[p]) if Gb else (G - votes[p]) gew = in_bez if Gb else votes for step in range(0, 1001): s = step / 1000 v = {o: votes[o] + (1 - s) * M * gew[o] / rest_g for o in votes if o != p} v[p] = votes[p] + s * M # ganze Stimmen: nach groessten Resten runden vi = {o: int(val) for o, val in v.items()} fehlt = round(sum(v.values())) - sum(vi.values()) for o in sorted(v, key=lambda o: -(v[o] - vi[o]))[:fehlt]: vi[o] += 1 z3 = hare(vi, seats)[0] if z3[p] > z[p]: k["noetiger_anteil"] = s k["folge"] = {o: (z[o], z3[o]) for o in votes if z3[o] != z[o]} break ergebnisse.append(dict( ags=ags, kreis=KREISE.get(ags[:5], x["link"]["title"]), wahl=x["wahl"].split(" - ")[0], sitze=seats, stimmen=G, hinweis=hinweis, stand=sz.get("footer", ""), ok=ok, link=f"{base}/praesentation/ergebnis.html?wahl_id={x['wahl_id']}&stimmentyp={x['stimmentyp']}&id={x['link']['id']}", parteien=[dict(partei=p, lang=lang[p], stimmen=votes[p], sitze=z[p], amtlich=amt[p]) for p in votes], reste=reste, knapp=knapp, fehlend=M, bezirke=bez, bezirke_ohne_daten=ohne_daten, stimmen_betroffen={p: v for p, v in in_bez.items() if v}, kann_kippen=any(k["moeglich"] for k in knapp))) for k in knapp: k["bewertung"] = bewerte(k["noetiger_anteil"], k["bezirksanteil"] if k["bezirksanteil"] is not None else k["kreisanteil"]) if k["noetiger_anteil"] is not None else None print(f"{ags} {ergebnisse[-1]['kreis'][:22]:22} S={seats:3} ok={ok} knapp={knapp[0]['partei'][:16]:16} " f"+{knapp[0]['stimmen']:>5} fehlend={M:>5} {'KANN' if ergebnisse[-1]['kann_kippen'] else ''}") for kreis, bez in sorted(fehl.items()): if not any(e["ags"][:5] == kreis for e in ergebnisse) and any(bez): ohne.append(dict(kreis=kreis, name=KREISE.get(kreis, kreis), fehlend=sum(b["fehlend"] for b in bez), bezirke=len(bez))) json.dump(dict(stand=STAND, kreise=ergebnisse, ohne_sitzangabe=ohne), open(OUT / "kippfaelle_kreistag.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) with open(OUT / "kippfaelle_kreistag.csv", "w", encoding="utf-8", newline="") as f: w = csv.writer(f) w.writerow(["kreis", "ags", "sitze", "stimmen", "fehlend", "bezirke_fehlend", "knappste_liste", "stimmen_bis_sitz", "zu_lasten", "kann_kippen", "noetiger_anteil_pct", "kreisanteil_pct", "abgleich_amtlich"]) for e in sorted(ergebnisse, key=lambda e: e["knapp"][0]["stimmen"] / max(e["fehlend"], 1)): k = e["knapp"][0] w.writerow([e["kreis"], e["ags"], e["sitze"], e["stimmen"], e["fehlend"], len(e["bezirke"]), k["partei"], k["stimmen"], ", ".join(k["auf_kosten"]), "ja" if e["kann_kippen"] else "nein", f"{100 * k['noetiger_anteil']:.1f}" if k["noetiger_anteil"] is not None else "", f"{100 * k['kreisanteil']:.1f}", "ja" if e["ok"] else "nein"]) print(len(ergebnisse), "Kreistage ->", OUT / "kippfaelle_kreistag.json") print("ohne Sitzangabe:", ohne)