"""Kippfaelle: Koennten die fehlenden Stimmen die Sitzverteilung oder die gewaehlten Personen aendern? Unabhaengig von kw_ebenen.py gerechnet (eigener CSV-Leser, eigene Sitzverteilung nach § 36 NKWG). Nur die Liste der Bezirke mit Urteil "Stimmen fehlen" und die Fehlmenge kommen aus zettelcheck.csv. Die Sitzverteilung wird gegen die votemanager-API geprueft (Antworten in /kippfaelle_api/). docker run --rm -v "$PWD":/work -u $(id -u):$(id -g) -w /work kw-ebenen \ python nds/out/kippfaelle.py python3 nds/out/kippfaelle_bericht.py Ergebnis: nds/out/stand//kippfaelle.json (Rechnung), KIPPFAELLE.md und kippfaelle.csv (Bericht). Vorher noetig (kw_ebenen.py, Ordner relativ zum Arbeitsverzeichnis): nds/data/stand// Rohdaten eines Abrufs mit manifest.csv (kw_ebenen.py abgleich) nds/out/stand//zettelcheck.csv Urteile zu diesem Stand (kw_ebenen.py zettelcheck --out ...) Der Rechenweg in Worten steht in Zettelcheck-Rechenweg.md (zettelcheck.gitlab.io/methode). """ import csv, json, re, sys, math, time from fractions import Fraction from pathlib import Path from statistics import median import requests STAND = sys.argv[1] if len(sys.argv) > 1 else "2026-09-25_1001" ROOT = Path("nds/data/stand") / STAND ZC = Path("nds/out/stand") / STAND / "zettelcheck.csv" OUT = Path(sys.argv[2] if len(sys.argv) > 2 else f"nds/out/stand/{STAND}") OUT.mkdir(parents=True, exist_ok=True) CACHE = OUT / "kippfaelle_api" # Sitzverteilung laut votemanager zum Zeitpunkt der Rechnung CACHE.mkdir(exist_ok=True) S = requests.Session() S.headers["User-Agent"] = "zettelcheck-analyse (f.a.w.ostmann+zettelcheck@gmail.com)" import certifi, tempfile _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 def fold(s): return (s.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") .replace("Ä", "Ae").replace("Ö", "Oe").replace("Ü", "Ue")) def wahl_typ(name): n = fold(name).lower() if re.search(r"ortsrat|ortsbeirat|stadtbezirksrat|bezirksrat", n): return "Ortsrat" if "kreis" in n or "regionsversammlung" in n: # Regionsversammlung: regionsweit wie ein Kreistag return "Kreistag" if "samtgemeinde" in n: return "Samtgemeinderat" return "Gemeinde-/Stadtrat" def num(v): v = (v or "").strip() if v == "": return 0 return int(float(v)) def bez(nr): return nr.strip().lstrip("0") or "0" # ---------------------------------------------------------------- Rohdaten lesen def lese(path): with open(path, encoding="utf-8-sig") as f: rows = list(csv.DictReader(f, delimiter=";")) if not rows or "gebiet-nr" not in rows[0]: return None kopf = list(rows[0].keys()) lists = sorted({int(m.group(1)) for c in kopf if (m := re.match(r"D(\d+)_4$", c))}) if not lists: return None cands = {p: sorted(int(m.group(1)) for c in kopf if (m := re.match(rf"D{p}_2_(\d+)$", c))) for p in lists} einzel = {p for p in lists if not cands[p]} bezirke = [] for r in rows: nr = (r.get("gebiet-nr") or "").strip() if not nr: continue b = dict(nr=nr, name=r["gebiet-name"].strip(), A=num(r.get("A")), B=num(r.get("B")), C2=num(r.get("C2")), D=num(r.get("D")), L={}, K={}, T={}) for p in lists: b["L"][p] = num(r.get(f"D{p}_1")) b["T"][p] = num(r.get(f"D{p}_4")) b["K"][p] = [num(r.get(f"D{p}_2_{k}")) for k in cands[p]] if not cands[p]: # Einzelwahlvorschlag: nur D_4 b["K"][p] = [b["T"][p] - b["L"][p]] b["art"] = "B" if b["A"] == 0 else "W" bezirke.append(b) for p in einzel: cands[p] = [1] wahl = rows[0]["wahl"].strip() wahl = wahlnamen(Path(path).parent).get(wahl, wahl) # umbenannte Wahl: Name wie im Register return dict(wahl=wahl, ags=rows[0]["ags"].strip(), lists=lists, cands=cands, einzel=einzel, bezirke=bezirke, datei=path) _od = {} def wahlnamen(d): """Aktueller Wahlname -> bisheriger (wahlnamen.json im Stand, geschrieben von kw_ebenen.py abgleich).""" p = Path(d) / "wahlnamen.json" return json.loads(p.read_text(encoding="utf-8")).get("namen", {}) if p.exists() else {} def opendata(d): if d not in _od: p = ROOT / d / "open_data.json" _od[d] = json.loads(p.read_text(encoding="utf-8")) if p.exists() else {} return _od[d] def parteinamen(d, wahl, datei=""): # Der Server benennt Wahlen in open_data.json mitunter um (z. B. "Kreiswahl" statt "Kreiswahl # Landkreis Northeim" wie in der CSV); dann ueber den Dateinamen in csvs[] zuordnen. od = opendata(d) namen = ([wahl] + [c.get("wahl") for c in od.get("csvs", []) if datei and c.get("url") == datei] + [n for n, k in wahlnamen(ROOT / d).items() if k == wahl]) eintraege = od.get("dateifelder", []) treffer = [e for n in namen for e in eintraege if e.get("name") == n] if not treffer: # alte Datei, Wahl inzwischen kuerzer benannt: eindeutiger Praefix ("Kreiswahl") w = fold(wahl).lower() treffer = [e for e in eintraege if e.get("name") and w.startswith(fold(e["name"]).lower())] treffer = treffer if len(treffer) == 1 else [] if treffer: return {int(re.sub(r"\D", "", p["feld"])): p["wert"] for p in treffer[0].get("parteien", [])} return {} def kurz(n): return re.split(r"\s+-\s+|, ", n)[0].strip() manifest = {} with open(ROOT / "manifest.csv", encoding="utf-8") as f: for r in csv.DictReader(f): manifest[(r["ags"], r["datei"])] = r["url"] # ---------------------------------------------------------------- § 36 NKWG def hare(votes: dict, seats: int, mehrheit=True): """Hare/Niemeyer mit exakten Bruechen, § 36 Abs. 2; Abs. 3 Mehrheitsklausel. -> (zuteilung, los)""" G = sum(votes.values()) if G == 0 or seats == 0: return {k: 0 for k in votes}, False 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()) def fuellen(z, rest, offen): order = sorted(rest, key=lambda k: -rest[k]) los = 0 < offen < len(order) and rest[order[offen - 1]] == rest[order[offen]] for k in order[:offen]: z[k] += 1 return los z1 = dict(z) los = fuellen(z1, dict(rest), offen) maj = [k for k, v in votes.items() if 2 * v > G] if mehrheit else [] if maj and 2 * z1[maj[0]] <= seats: # § 36 Abs. 3 z[maj[0]] += 1 rest.pop(maj[0]) los = fuellen(z, rest, offen - 1) return z, los return z1, los def verteile(L, K, seats): """L: {p: Listenstimmen}, K: {p: [Personenstimmen in Listenfolge]} -> (sitze je p, gewaehlt-set, los)""" tot = {p: L[p] + sum(K[p]) for p in L} z, los = hare(tot, seats) gew = set() for p, n in z.items(): if n == 0: continue ks = K[p] if len(ks) == 1 and L[p] == 0: # Einzelwahlvorschlag gew.add((p, 0)) continue aufteil, l2 = hare({"L": L[p], "P": sum(ks)}, n, mehrheit=False) los |= l2 rang = sorted([i for i in range(len(ks)) if ks[i] > 0], key=lambda i: -ks[i]) np_ = min(aufteil["P"], len(rang)) if 0 < np_ < len(rang) and ks[rang[np_ - 1]] == ks[rang[np_]]: los = True for i in rang[:np_]: gew.add((p, i)) rest = n - np_ for i in range(len(ks)): if rest == 0: break if (p, i) not in gew: gew.add((p, i)) rest -= 1 return z, frozenset(gew), los # ---------------------------------------------------------------- votemanager-API 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) try: r = S.get(url, timeout=30) except Exception as e: print(" API-Fehler", url, type(e).__name__) return None if r.status_code != 200: return None fn.write_text(r.text) return r.json() def amtlich(d, datei, wahl): """Sitze und gewaehlte Personen laut votemanager-Praesentation.""" url = manifest.get((d, datei), "") if "/daten/opendata/" not in url: return None base = url.split("/daten/opendata/")[0] + "/daten/api/" kand = [e for e in opendata(d).get("ergebnisgrafiken", []) if e["wahl"].startswith(wahl + " - ")] beste = None for e in kand: j = api(f"{base}wahl_{e['wahl_id']}/ergebnis_{e['link']['id']}_{e['stimmentyp']}.json") if not j: continue s = j.get("Komponente", {}).get("sitze") if not s: continue m = re.search(r"(\d+)\s+Sitze", s["tortenDiagramm"].get("footer", "")) if not m: continue info = dict(sitze=int(m.group(1)), parteien={x["label"]: x["sitze"] for x in s["tortenDiagramm"]["entries"]}, tooltip={x.get("tooltip", ""): x["label"] for x in s["tortenDiagramm"]["entries"]}, gewaehlt=[tuple(z) for z in s.get("tabelle", {}).get("zeilen", [])], untertitel=s["tortenDiagramm"]["title"].get("subtitle", ""), link=url.split("/daten/opendata/")[0] + f"/praesentation/ergebnis.html?wahl_id={e['wahl_id']}&stimmentyp={e['stimmentyp']}&id={e['link']['id']}", kandidaten={}, bereiche=sorted({g[2].rsplit(", ", 1)[0] for g in s.get("tabelle", {}).get("zeilen", []) if len(g) >= 3}), hinweis=" ".join(j["Komponente"].get("info", {}).get("hinweis", [])), stand=s["tortenDiagramm"].get("footer", "")) for zl in j["Komponente"]["tabelle"]["zeilen"]: lab = zl["label"]["labelKurz"] if lab.endswith("Summe Kandidaten-Stimmen"): pk = lab.rsplit(" - ", 1)[0] info["kandidaten"][pk] = [(sz["label"]["labelKurz"], int(sz["zahl"].replace(".", ""))) for sz in zl.get("sub_zeilen", [])] if beste is None or info["sitze"] > beste["sitze"]: beste = info return beste # ---------------------------------------------------------------- Hilfen fuer die Szenarien def summen(E, zusatz=None): L = {p: sum(b["L"][p] for b in E["bezirke"]) for p in E["lists"]} K = {p: [sum(b["K"][p][i] for b in E["bezirke"]) for i in range(len(E["cands"][p]))] for p in E["lists"]} if zusatz: for p, (l, ks) in zusatz.items(): L[p] += l K[p] = [a + x for a, x in zip(K[p], ks)] return L, K def runde(werte: dict, n: int): """Ganzzahlige Aufteilung nach groessten Resten (fuer Stimmenzuschlaege).""" t = sum(werte.values()) if t <= 0 or n <= 0: return {k: 0 for k in werte} q = {k: v * n / t for k, v in werte.items()} z = {k: int(math.floor(x)) for k, x in q.items()} for k in sorted(q, key=lambda k: -(q[k] - z[k]))[: n - sum(z.values())]: z[k] += 1 return z def zuschlag(E, fb, anteile: dict): """Verteilt die Fehlmengen fb {bezirk-idx: M} nach Parteianteilen {p: gewicht} je Bezirk. Innerhalb der Partei wie im Bezirk selbst: Liste vs. Personen, Personen nach Bezirksanteil. anteile: {bezirk-idx: {p: gewicht}}""" zu = {p: [0, [0] * len(E["cands"][p])] for p in E["lists"]} for j, M in fb.items(): b = E["bezirke"][j] je_p = runde(anteile[j], M) for p, m in je_p.items(): if m == 0: continue ml = E.get("ml", {}).get(j) pers = {i: v for i, v in enumerate(b["K"][p])} if ml is None: # wie im Bezirk selbst gew = {"L": b["L"][p], **pers} if sum(gew.values()) == 0: gew = {"L": 1} r = runde(gew, m) else: # geschaetzter Listenanteil der Fehlmenge ml_n = round(m * ml) if sum(pers.values()) else m r = runde(pers, m - ml_n) if sum(pers.values()) else {} r["L"] = ml_n zu[p][0] += r.pop("L", 0) for i, v in r.items(): zu[p][1][i] += v return {p: (l, ks) for p, (l, ks) in zu.items()} def bewerte_anteil(ziel, ref, person=False): """Anteil im vollstaendigen Bezirk (ziel) gegen den erwarteten Anteil (ref). realistisch: hoechstens 1,75-fach und hoechstens 10 Punkte (Beispiel 12 % -> 20 %) denkbar: hoechstens 2,5-fach und hoechstens 15 Punkte Kleine Abstaende (1 bzw. 2 Punkte, bei Personen 0,5 bzw. 1) gelten immer als realistisch bzw. denkbar.""" if ziel is None: return "unmoeglich" if abs(ziel - ref) < 5e-4: return "schon bei Referenz" boden = (0.005, 0.01) if person else (0.01, 0.02) hi, lo = max(ziel, ref), min(ziel, ref) f = hi / lo if lo > 0 else float("inf") d = hi - lo if (f <= 1.75 and d <= 0.10) or d <= boden[0]: return "realistisch" if (f <= 2.5 and d <= 0.15) or d <= boden[1]: return "denkbar" return "unrealistisch" def bewerte_fehlend(s, refs, M): """Anteil an den fehlenden Stimmen (s) gegen den erwarteten (refs: Schaetzung A und B). Gleiche Grenzen wie bewerte_anteil, gemessen an der naeheren Schaetzung. Zweites Kriterium, weil der Anteil im vollstaendigen Bezirk nur wenig schwankt, wenn die gezaehlten Stimmen feststehen: 4 Punkte im Bezirk koennen 20 Punkte unter den fehlenden Stimmen sein. Abweichungen bis 3 Stimmen: realistisch.""" if s is None: return "unmoeglich" refs = [r for r in refs if r is not None] if min(abs(s - r) for r in refs) * M <= 3: return "realistisch" urteile = [bewerte_anteil(s, r) for r in refs] return min(urteile, key=lambda u: ["schon bei Referenz", "realistisch", "denkbar", "unrealistisch"].index(u)) def schlechter(a, b): reihe = ["schon bei Referenz", "realistisch", "denkbar", "unrealistisch", "unmoeglich"] return max(a, b, key=reihe.index) def haltbar(tot: dict, M: int, seats: int, ziel: dict, schritte=4000): """Gibt es irgendeine Verteilung von M Zusatzstimmen auf die Listen, bei der die Sitze je Liste 'ziel' bleiben? Fuer eine feste Schwelle theta der Zahlenbruchteile ist das ein Kastenproblem je Liste; theta wird fein abgetastet, jeder Treffer mit hare() bestaetigt. -> Zusatzstimmen je Liste oder None.""" T = sum(tot.values()) + M for s in range(schritte): th = s / schritte lo, hi, ok = {}, {}, True for p, v in tot.items(): n_ = ziel[p] a = (n_ - 1 + th) * T / seats - v b = (n_ + th) * T / seats - v l_ = 0 if n_ == 0 else max(0, math.floor(a) + 1) h_ = math.floor(b) if h_ < l_: ok = False break lo[p], hi[p] = l_, h_ if not ok or not (sum(lo.values()) <= M <= sum(hi.values())): continue x, r = dict(lo), M - sum(lo.values()) for p in tot: d_ = min(r, hi[p] - lo[p]) x[p] += d_ r -= d_ if hare({p: tot[p] + x[p] for p in tot}, seats)[0] == ziel: return x return None def bewerte(s, r, person=False): """Wie weit muss der Anteil an den fehlenden Stimmen vom Referenzanteil abweichen?""" if s is None: return "unmoeglich" if abs(s - r) < 1e-9: return "schon bei Referenz" if person: hoch = [max(1.5 * r, r + 0.01), max(2 * r, r + 0.02)] tief = [min(r / 1.5, r - 0.01), min(r / 2, r - 0.02)] else: hoch = [max(1.5 * r, r + 0.05), max(2 * r, r + 0.10)] tief = [min(r / 1.5, r - 0.05), min(r / 2, r - 0.10)] if s > r: return "realistisch" if s <= hoch[0] else ("denkbar" if s <= hoch[1] else "unrealistisch") return "realistisch" if s >= tief[0] else ("denkbar" if s >= tief[1] else "unrealistisch") def gleich(a, b, E): """Parteiebene: nur die Sitze je Liste.""" return a[0] == b[0] def personenwechsel(base, neu, E): if E.get("mehrere_wb") or base[0] != neu[0]: return "" raus, rein = sorted(base[1] - neu[1]), sorted(neu[1] - base[1]) if not raus and not rein: return "" return (", ".join(knam(E, p, i) for p, i in raus) + " → " + ", ".join(knam(E, p, i) for p, i in rein)) def aenderung(base, neu, namen, E): zb, gb, _ = base zn, gn, _ = neu txt = [] for p in E["lists"]: if zb[p] != zn[p]: txt.append(f"{namen.get(p, p)} {zb[p]}->{zn[p]}") if not txt and not E.get("mehrere_wb"): raus = sorted(gb - gn) rein = sorted(gn - gb) if raus or rein: txt.append("Personen: " + ", ".join(f"-{knam(E, p, i)}" for p, i in raus) + " / " + ", ".join(f"+{knam(E, p, i)}" for p, i in rein)) return "; ".join(txt) def knam(E, p, i): return E.get("kname", {}).get((p, i), f"{E['namen'].get(p, p)} Nr.{i + 1}") # ---------------------------------------------------------------- Hauptlauf zc = list(csv.DictReader(open(ZC, encoding="utf-8"))) fehl = {} for r in zc: if "Stimmen fehlen" not in r["urteil"]: continue if wahl_typ(r["wahl"]) == "Kreistag": continue fehl.setdefault((r["ags"], r["wahl"]), []).append(r) # Index aller Wahlbezirks-Dateien je Instanzordner index = {} for d in sorted(p.name for p in ROOT.iterdir() if p.is_dir()): for f in sorted((ROOT / d).glob("Open-Data-*.csv")): if not re.search(r"Wahlbezirk|Stimmbezirk", f.name): continue E = lese(f) if E: index.setdefault(d, []).append(E) ergebnisse = [] for (ags, wahl), zeilen in sorted(fehl.items()): d = ags Es = [E for E in index.get(d, []) if E["wahl"] == wahl] if len(Es) != 1: print("?? keine eindeutige Datei", ags, wahl, len(Es)) continue E = Es[0] namen_lang = parteinamen(d, wahl, E["datei"].name) E["namen"] = {p: kurz(namen_lang.get(p, f"Liste {p}")) for p in E["lists"]} namen = E["namen"] am = amtlich(d, E["datei"].name, wahl) if not am: print("?? keine Sitzzahl", ags, wahl) continue seats = am["sitze"] E["mehrere_wb"] = len(am["bereiche"]) > 1 m_ = re.search(r"(\d+) von (\d+) Ergebnissen", am["hinweis"]) api_vollst = not m_ or m_.group(1) == m_.group(2) # Kurzbezeichnungen wie in der votemanager-Praesentation (tooltip = langer Name) for p in E["lists"]: lang = namen_lang.get(p, "") if lang in am["tooltip"]: namen[p] = am["tooltip"][lang] elif kurz(lang) in am["tooltip"]: namen[p] = am["tooltip"][kurz(lang)] L, K = summen(E) base = verteile(L, K, seats) # Namen der Kandidaten ueber die Stimmenzahl zuordnen kname = {} for p in E["lists"]: api_k = None for pk, lst in am["kandidaten"].items(): if kurz(pk) == namen[p] or pk == namen[p]: api_k = lst if api_k is None and len(E["lists"]) == 1 and am["kandidaten"]: api_k = list(am["kandidaten"].values())[0] if not api_k: continue for i, v in enumerate(K[p]): treffer = [x[0] for x in api_k if x[1] == v] if len(treffer) == 1: kname[(p, i)] = treffer[0] elif treffer: kname[(p, i)] = f"Listenplatz {i + 1} ({' oder '.join(treffer)}, gleichauf)" E["kname"] = kname # Pruefung gegen votemanager ok_sitze = all(am["parteien"].get(namen[p], 0) == base[0][p] for p in E["lists"]) and \ sum(base[0].values()) == sum(am["parteien"].values()) amt_gew = sorted(int(g[3].replace(".", "")) for g in am["gewaehlt"] if len(g) >= 4 and g[3].replace(".", "").isdigit()) mein_gew = sorted(K[p][i] for p, i in base[1]) ok_pers = (amt_gew == mein_gew) if amt_gew else None # Fehlmengen je Bezirk fb, info_b = {}, [] for r in zeilen: j = next((i for i, b in enumerate(E["bezirke"]) if b["nr"] == r["gebiet-nr"].strip()), None) if j is None: print("?? Bezirk fehlt", ags, wahl, r["gebiet-nr"]) continue fb[j] = int(round(float(r["fehlend_ggue_median"]))) if not fb: continue M = sum(fb.values()) # Welcher Teil der Fehlmenge sind Listenstimmen? Listenanteil der uebrigen Bezirke als Massstab. lams = [sum(b["L"].values()) / b["D"] for i, b in enumerate(E["bezirke"]) if i not in fb and b["D"] > 0] lam = median(lams) if lams else None E["ml"], ml_info = {}, {} for j in fb: b = E["bezirke"][j] if lam is None: continue Lsoll = lam * (b["D"] + fb[j]) ML = min(fb[j], max(0.0, Lsoll - sum(b["L"].values()))) E["ml"][j] = ML / fb[j] ml_info[j] = dict(listenanteil=sum(b["L"].values()) / max(b["D"], 1), listenanteil_median=lam, fehlmenge_liste=round(ML)) # Referenz je Bezirk: Parallelwahl im selben Bezirk (gleiche Waehlerzahl +-6 %) def nkey(x): return re.sub(r"[^a-z0-9]", "", fold(x).lower()) lang_e = {p: nkey(namen_lang.get(p, "")) for p in E["lists"]} anteil_prop, anteil_par, ref_share, par_info = {}, {}, {}, {} for j in fb: b = E["bezirke"][j] anteil_prop[j] = {p: b["T"][p] for p in E["lists"]} best = None for E2 in index.get(d, []): if E2 is E: continue for b2 in E2["bezirke"]: if bez(b2["nr"]) != bez(b["nr"]) or b2["art"] != b["art"]: continue if not b2["B"] or abs(b2["B"] / max(b["B"], 1) - 1) > 0.06: continue if b2["D"] / max(b2["C2"], 1) < 2.6: # Parallelwahl selbst unvollstaendig continue n2 = parteinamen(d, E2["wahl"], E2["datei"].name) m2 = {nkey(n2.get(p, "")): b2["T"][p] for p in E2["lists"]} treff = {p: m2[lang_e[p]] for p in E["lists"] if lang_e[p] and lang_e[p] in m2} abdeck = sum(b["T"][p] for p in treff) / max(b["D"], 1) if abdeck < 0.7: continue kand = (abdeck, E2["wahl"], b2, treff, b2["D"], E2, n2) if best is None or kand[0] > best[0]: best = kand if best: abdeck, w2, b2, treff, D2, E2, n2 = best # Ueblicher Unterschied zwischen beiden Wahlen: in den *anderen* Bezirken gemessen # (Anteil innerhalb der gemeinsamen Listen), Median je Partei. p2 = {p: next(q for q in E2["lists"] if nkey(n2.get(q, "")) == lang_e[p]) for p in treff} diffs = {p: [] for p in treff} for i, bo in enumerate(E["bezirke"]): if i in fb or not bo["D"]: continue bo2 = next((x for x in E2["bezirke"] if bez(x["nr"]) == bez(bo["nr"]) and x["art"] == bo["art"] and x["B"] and abs(x["B"] / max(bo["B"], 1) - 1) <= 0.06 and x["D"] / max(x["C2"], 1) >= 2.6), None) if bo2 is None or bo["D"] / max(bo["C2"], 1) < 2.6: continue s1 = sum(bo["T"][p] for p in treff) or 1 s2 = sum(bo2["T"][p2[p]] for p in treff) or 1 for p in treff: diffs[p].append(bo["T"][p] / s1 - bo2["T"][p2[p]] / s2) n_off = min((len(v) for v in diffs.values()), default=0) off = {p: (median(diffs[p]) if diffs[p] else 0.0) for p in treff} t2 = sum(treff.values()) or 1 anteil2 = {p: max(0.0, treff[p] / t2 + off[p]) for p in treff} norm = sum(anteil2.values()) or 1 anteil2 = {p: v / norm for p, v in anteil2.items()} # Erwartete Stimmen je Partei im vollstaendigen Bezirk, bezogen auf den Teil des # Bezirks, den die gemeinsamen Listen abdecken. Dsoll = b["D"] + fb[j] teil_ist = sum(b["T"][p] for p in treff) teil_soll = teil_ist + fb[j] # Listen ohne Gegenstueck in der Parallelwahl (lokale Waehlergruppen, Einzelbewerber) # bekommen ihren Bezirksanteil der Fehlmenge; nur der Rest geht nach der Parallelwahl. ohne = {p: fb[j] * b["T"][p] / max(b["D"], 1) for p in E["lists"] if p not in treff} rest_m = fb[j] - sum(ohne.values()) teil_soll = teil_ist + rest_m gap = {p: max(0.0, anteil2[p] * teil_soll - b["T"][p]) for p in treff} sg = sum(gap.values()) if sg > 0: w_ = {p: rest_m * gap[p] / sg for p in treff} else: st = sum(b["T"][p] for p in treff) or 1 w_ = {p: rest_m * b["T"][p] / st for p in treff} w_.update(ohne) anteil_par[j] = {p: w_.get(p, 0.0) for p in E["lists"]} ref_share[j] = {p: (anteil2[p] * teil_soll / Dsoll if p in treff else None) for p in E["lists"]} par_info[j] = (f"{w2} (Bezirk {b2['nr']}, {b2['B']} Waehler, gemeinsame Listen decken {abdeck:.0%}, " f"Unterschied beider Wahlen aus {n_off} anderen Bezirken)") else: anteil_par[j] = anteil_prop[j] ref_share[j] = {p: None for p in E["lists"]} par_info[j] = "keine passende Parallelwahl" # Schaetzung L, Listenluecke: Fehlen einer Liste die Listenstimmen, faellt ihr Verhaeltnis Listen- zu # Personenstimmen im Bezirk unter das der uebrigen Bezirke. Luecke je Liste = Personenstimmen im Bezirk # mal dieses Verhaeltnis minus gezaehlte Listenstimmen. Nur mit mindestens 3 Vergleichsbezirken und nur, # wenn die Luecken mindestens die Haelfte der Fehlmenge erklaeren; der Rest geht wie in Schaetzung A. anteil_luecke, luecke_info = {}, {} rest_b = [b for i, b in enumerate(E["bezirke"]) if i not in fb] for j in fb: b = E["bezirke"][j] luecke = {} for p in E["lists"]: Lr = sum(x["L"][p] for x in rest_b) Pr = sum(sum(x["K"][p]) for x in rest_b) P = sum(b["K"][p]) if p in E["einzel"] or Pr == 0 or P == 0: continue luecke[p] = max(0.0, P * Lr / Pr - b["L"][p]) G_ = sum(luecke.values()) if len(rest_b) < 3 or G_ < 0.5 * fb[j]: luecke_info[j] = dict(anwendbar=False, luecke=round(G_), vergleichsbezirke=len(rest_b)) continue if G_ >= fb[j]: w_ = {p: fb[j] * v / G_ for p, v in luecke.items()} else: w_ = dict(luecke) for p in E["lists"]: w_[p] = w_.get(p, 0.0) + (fb[j] - G_) * b["T"][p] / max(b["D"], 1) anteil_luecke[j] = {p: w_.get(p, 0.0) for p in E["lists"]} luecke_info[j] = dict(anwendbar=True, luecke=round(G_), vergleichsbezirke=len(rest_b), je_liste={namen[p]: round(v) for p, v in luecke.items() if v >= 1}) luecke_ok = len(anteil_luecke) == len(fb) sz_prop = verteile(*summen(E, zuschlag(E, fb, anteil_prop)), seats) sz_par = verteile(*summen(E, zuschlag(E, fb, anteil_par)), seats) sz_luecke = verteile(*summen(E, zuschlag(E, fb, anteil_luecke)), seats) if luecke_ok else None # Gibt es ueberhaupt eine Verteilung der Fehlmenge, bei der die Sitze je Liste bleiben? tot_listen = {p: L[p] + sum(K[p]) for p in E["lists"]} halt_bsp = haltbar(tot_listen, M, seats, base[0]) # Personen: robust nur, wenn der Wechsel bei "nur Listenstimmen" und "nur Personenstimmen" gleich ist ml_est = E["ml"] pers = {} for name, ml in (("geschaetzt", ml_est), ("nur_liste", {j: 1.0 for j in fb}), ("nur_personen", {j: 0.0 for j in fb})): E["ml"] = ml for sz, an in (("A", anteil_prop), ("B", anteil_par)): pers[f"{sz}_{name}"] = personenwechsel(base, verteile(*summen(E, zuschlag(E, fb, an)), seats), E) E["ml"] = ml_est # Schwellen je Partei: Partei p bekommt Anteil s der Fehlmenge, der Rest wie im Bezirk Dsum = sum(E["bezirke"][j]["D"] for j in fb) Tp = {p: sum(E["bezirke"][j]["T"][p] for j in fb) for p in E["lists"]} # Ausgangspunkt der Schwellen ist die Hauptschaetzung: B, ohne Parallelwahl die Listenluecke (L), sonst A. # Eine Liste bekommt den Anteil s der Fehlmenge, die uebrigen teilen den Rest im Verhaeltnis dieser Schaetzung. basis = {j: (anteil_par[j] if not par_info[j].startswith("keine") else anteil_luecke[j] if luecke_ok else anteil_prop[j]) for j in fb} def lauf(p, s): an = {} for j in fb: rest = {q: basis[j][q] for q in E["lists"] if q != p} rt = sum(rest.values()) if rt <= 0: rest = {q: E["bezirke"][j]["T"][q] for q in E["lists"] if q != p} rt = sum(rest.values()) or 1 an[j] = {q: (s if q == p else (1 - s) * rest[q] / rt) * 1e6 for q in E["lists"]} return verteile(*summen(E, zuschlag(E, fb, an)), seats) schwellen = [] grid = [i / 200 for i in range(201)] for p in E["lists"]: a_now = Tp[p] / max(Dsum, 1) refs = [ref_share[j][p] for j in fb if ref_share[j][p] is not None] ref = sum(refs) / len(refs) if refs else None s_ref = sum(fb[j] * basis[j][p] / (sum(basis[j].values()) or 1) for j in fb) / M erw = (Tp[p] + s_ref * M) / (Dsum + M) # Anteil im vollstaendigen Bezirk nach Schaetzung andere = [b["T"][p] / b["D"] for i, b in enumerate(E["bezirke"]) if i not in fb and b["D"] > 0] erg = {x: lauf(p, x) for x in grid} bei_ref = lauf(p, s_ref) kippt_bei_ref = not gleich(bei_ref, base, E) # gesucht: naechster Anteil, bei dem sich das Ergebnis gegenueber dem Zustand bei s_ref umkehrt ziel_menge = [x for x in grid if gleich(erg[x], base, E) == kippt_bei_ref] if not ziel_menge: if kippt_bei_ref: schwellen.append(dict(partei=namen[p], art="haelt nie", s=None, s_ref=s_ref, jetzt=a_now, ref=ref, erw=erw, ziel=None, bewertung="unmoeglich", was=aenderung(base, bei_ref, namen, E), andere_n=len(andere))) continue s_n = min(ziel_menge, key=lambda x: abs(x - s_ref)) schritt = 0.001 if s_n >= s_ref else -0.001 x = s_ref while (x - s_n) * schritt < 0 and gleich(lauf(p, x), base, E) != kippt_bei_ref: x = round(x + schritt, 6) if gleich(lauf(p, x), base, E) != kippt_bei_ref: x = s_n neu = lauf(p, x) if not kippt_bei_ref else bei_ref ziel = (Tp[p] + x * M) / (Dsum + M) b_voll = bewerte_anteil(ziel, erw) b_fehl = bewerte_fehlend(x, [s_ref, a_now], M) schwellen.append(dict( bewertung_voll=b_voll, bewertung_fehlend=b_fehl, anteil_gezaehlt=a_now, partei=namen[p], art=("haelt nur" if kippt_bei_ref else "kippt"), richtung="mehr" if x > s_ref else "weniger", s=x, s_ref=s_ref, jetzt=a_now, ref=ref, erw=erw, ziel=ziel, stimmen_noetig=round(x * M), stimmen_ref=round(s_ref * M), andere_min=min(andere) if andere else None, andere_max=max(andere) if andere else None, andere_n=len(andere), andere_erreicht=sum(1 for y in andere if (y >= ziel if x >= s_ref else y <= ziel)), bewertung=schlechter(b_voll, b_fehl), was=aenderung(base, neu, namen, E))) # Knappheit: wie viele Listenstimmen mehr haette jede Partei fuer einen weiteren Sitz gebraucht? knapp = [] for q in E["lists"]: if q in E.get("einzel", ()) and base[0][q] >= 1: continue lo, hi = 0, max(2 * M, 50) def sitze_mit(x): L2 = dict(L); L2[q] += x return verteile(L2, K, seats)[0] if sitze_mit(hi)[q] <= base[0][q]: continue while hi - lo > 1: mid = (lo + hi) // 2 if sitze_mit(mid)[q] > base[0][q]: hi = mid else: lo = mid z2 = sitze_mit(hi) verlierer = [namen[x] for x in E["lists"] if z2[x] < base[0][x]] knapp.append(dict(partei=namen[q], stimmen=hi, auf_kosten=", ".join(verlierer))) knapp.sort(key=lambda x: x["stimmen"]) # Personen: bester Nichtgewaehlter jeder Liste gegen die Grenze personen = [] for p in (E["lists"] if not E["mehrere_wb"] else []): n = base[0][p] ks = K[p] if n == 0 or len(ks) <= n: continue gew = sorted([i for i in range(len(ks)) if (p, i) in base[1]], key=lambda i: ks[i]) nicht = sorted([i for i in range(len(ks)) if (p, i) not in base[1]], key=lambda i: -ks[i]) if not gew or not nicht: continue k = nicht[0] # Gegenueber: schwaechster Gewaehlter, der ueber die Personenstimmen vor k liegt vor = [i for i in gew if ks[i] >= ks[k]] if vor: gew = [min(vor, key=lambda i: ks[i])] + gew zu0 = zuschlag(E, fb, {j: {q: (v if q == p else 0) for q, v in anteil_prop[j].items()} for j in fb}) zu0 = {q: (v if q == p else (0, [0] * len(E["cands"][q]))) for q, v in zu0.items()} # ... skaliert auf den proportionalen Anteil der Partei zu_p = zuschlag(E, fb, anteil_prop) zu0 = {q: (zu_p[q] if q == p else (0, [0] * len(E["cands"][q]))) for q in E["lists"]} bedarf = None for extra in range(0, M + 1): zu = dict(zu0) l, kk = zu[p] kk = list(kk) kk[k] += extra zu[p] = (l, kk) neu = verteile(*summen(E, zu), seats) if neu[0] != base[0]: break if (p, k) in neu[1]: bedarf = extra break kb = sum(E["bezirke"][j]["K"][p][k] for j in fb) prop_k = zu0[p][1][k] r_k = kb / max(Dsum, 1) s_k = (prop_k + bedarf) / M if bedarf is not None else None ziel_k = (kb + prop_k + bedarf) / (Dsum + M) if bedarf is not None else None personen.append(dict(partei=namen[p], kandidat=knam(E, p, k), stimmen=ks[k], letzter=knam(E, p, gew[0]), letzter_stimmen=ks[gew[0]], im_bezirk=kb, prop=prop_k, bedarf=bedarf, sitze_liste=n, s=s_k, r=r_k, ziel=ziel_k, gleichauf=ks[k] == ks[gew[0]], bewertung=bewerte_anteil(ziel_k, r_k, person=True) if ziel_k is not None else "unmoeglich")) ergebnisse.append(dict( ags=ags, wahl=wahl, typ=wahl_typ(wahl), datei=E["datei"].name, sitze=seats, link=am["link"], status=am["untertitel"], stimmen=sum(sum(L.values()) for _ in [0]) + sum(sum(v) for v in K.values()), M=M, bezirke=[dict(nr=E["bezirke"][j]["nr"], name=E["bezirke"][j]["name"], fehlend=fb[j], D=E["bezirke"][j]["D"], C2=E["bezirke"][j]["C2"], parallel=par_info[j], liste=ml_info.get(j), urteil=next(r["urteil"] for r in zeilen if r["gebiet-nr"].strip() == E["bezirke"][j]["nr"]), parteien={namen[p]: dict(ist=E["bezirke"][j]["T"][p], liste=E["bezirke"][j]["L"][p], ref=ref_share[j][p]) for p in E["lists"]}) for j in fb], parteien=[dict(partei=namen[p], stimmen=L[p] + sum(K[p]), sitze=base[0][p], rest=float(Fraction((L[p] + sum(K[p])) * seats, sum(L.values()) + sum(sum(v) for v in K.values()))) ) for p in E["lists"]], mehrere_wb=E["mehrere_wb"], wahlbereiche=am["bereiche"], api_vollst=api_vollst, api_stand=am["stand"], los=base[2], ok_sitze=ok_sitze, ok_pers=ok_pers, amtlich_parteien=am["parteien"], amt_gew=sorted(amt_gew), mein_gew=sorted(mein_gew), amt_gew_roh=am["gewaehlt"][:60], szenario_prop=aenderung(base, sz_prop, namen, E) if not gleich(base, sz_prop, E) else "", szenario_par=aenderung(base, sz_par, namen, E) if not gleich(base, sz_par, E) else "", luecke_anwendbar=luecke_ok, szenario_luecke=(aenderung(base, sz_luecke, namen, E) if not gleich(base, sz_luecke, E) else "") if luecke_ok else "", luecke=[dict(nr=E["bezirke"][j]["nr"], **luecke_info[j]) for j in fb], haltbar=halt_bsp is not None, halt_beispiel={namen[p]: v for p, v in halt_bsp.items() if v} if halt_bsp else None, anteil_gezaehlt={namen[p]: Tp[p] / max(Dsum, 1) for p in E["lists"]}, personen_szenarien=pers, schwellen=schwellen, personen=personen, knapp=knapp)) print(f"{ags} {wahl[:45]:45} Sitze {seats:2} M={M:4} ok={ok_sitze}/{ok_pers} " f"prop='{ergebnisse[-1]['szenario_prop']}' par='{ergebnisse[-1]['szenario_par']}' luecke='{ergebnisse[-1]['szenario_luecke']}' haltbar={ergebnisse[-1]['haltbar']}") json.dump(ergebnisse, open(OUT / "kippfaelle.json", "w"), ensure_ascii=False, indent=1, default=str) print(len(ergebnisse), "Wahlen ->", OUT / "kippfaelle.json")