diff --git a/app/auswertungen.py b/app/auswertungen.py index 8ca063a..faa037e 100644 --- a/app/auswertungen.py +++ b/app/auswertungen.py @@ -614,6 +614,106 @@ def aggregate_stimm_index_pro_wert( } +def _quarter_for(datum: str) -> Optional[str]: + """ISO-Datum zu Quartal-Bucket "YYYY-Qn" (z.B. "2024-Q1" für Q1/2024).""" + if not datum or len(datum) < 7: + return None + try: + year = int(datum[:4]) + month = int(datum[5:7]) + if not 1 <= month <= 12: + return None + q = (month - 1) // 3 + 1 + return f"{year}-Q{q}" + except (ValueError, IndexError): + return None + + +def aggregate_stimm_index_zeitreihe( + parteien: Optional[list[str]] = None, + filter_bl: Optional[str] = None, + filter_wp: Optional[str] = None, + exclude_antragsteller: bool = True, + min_n_per_bucket: int = 3, + db_path: Optional[Path] = None, +) -> dict: + """Stimm-Index ueber die Zeit, gebuckt nach Quartal — pro Fraktion eine + Linie. Macht regionale Drift sichtbar (#168). + + Pro (Fraktion, Quartal) ein stimm_index analog Aussage 1. Buckets mit + n_ja < min_n_per_bucket ODER n_nein < min_n_per_bucket bekommen + stimm_index=null (im Chart als Lücke). + + `parteien=None` → alle Fraktionen mit ≥1 Datenpunkt; sonst nur die + angegebenen. + """ + rows = _load_assessments_with_votes(filter_bl, filter_wp, db_path) + + ja: defaultdict[tuple[str, str], list[float]] = defaultdict(list) + nein: defaultdict[tuple[str, str], list[float]] = defaultdict(list) + buckets_seen: set[str] = set() + parteien_seen: set[str] = set() + + for row in rows: + bucket = _quarter_for(row["datum"]) + if not bucket: + continue + buckets_seen.add(bucket) + skip = row["antragsteller"] if exclude_antragsteller else set() + score = row["gwoe_score"] + for f in row["ja"] - skip: + parteien_seen.add(f) + ja[(f, bucket)].append(score) + for f in row["nein"] - skip: + parteien_seen.add(f) + nein[(f, bucket)].append(score) + + parteien_filter = ( + set(parteien) if parteien else parteien_seen + ) + parteien_out = sorted(parteien_seen & parteien_filter) + buckets_sorted = sorted(buckets_seen) + + series: dict[str, list[Optional[float]]] = {} + detail: dict[str, dict[str, dict]] = {} + for partei in parteien_out: + line = [] + partei_detail = {} + for b in buckets_sorted: + n_ja = len(ja[(partei, b)]) + n_nein = len(nein[(partei, b)]) + avg_ja = _avg(ja[(partei, b)]) + avg_nein = _avg(nein[(partei, b)]) + ausreichend = (n_ja >= min_n_per_bucket + and n_nein >= min_n_per_bucket) + idx = (round(avg_ja - avg_nein, 2) + if avg_ja is not None and avg_nein is not None + and ausreichend else None) + line.append(idx) + partei_detail[b] = { + "stimm_index": idx, + "n_ja": n_ja, + "n_nein": n_nein, + "ausreichend": ausreichend, + } + series[partei] = line + detail[partei] = partei_detail + + return { + "buckets": buckets_sorted, + "fraktionen": parteien_out, + "series": series, + "detail": detail, + "n_assessments_matched": len({r["drucksache"] for r in rows}), + "filter": { + "bundesland": filter_bl, + "wahlperiode": filter_wp, + "exclude_antragsteller": exclude_antragsteller, + "min_n_per_bucket": min_n_per_bucket, + }, + } + + def aggregate_stimm_index_pro_gruppe( filter_bl: Optional[str] = None, filter_wp: Optional[str] = None, diff --git a/app/main.py b/app/main.py index 18ca4d6..2bc93fe 100644 --- a/app/main.py +++ b/app/main.py @@ -2370,6 +2370,27 @@ async def auswertungen_stimm_index_cross_bl( ) +@app.get("/api/auswertungen/stimm-index-zeitreihe") +async def auswertungen_stimm_index_zeitreihe( + bundesland: Optional[str] = None, + wahlperiode: Optional[str] = None, + parteien: Optional[str] = None, # comma-separated + exclude_antragsteller: bool = True, + min_n_per_bucket: int = 3, +): + """Stimm-Index ueber Zeit (Quartal × Fraktion) — Drift im Stimmverhalten + waehrend der Wahlperiode (#168).""" + from .auswertungen import aggregate_stimm_index_zeitreihe + parteien_list = parteien.split(",") if parteien else None + return aggregate_stimm_index_zeitreihe( + parteien=parteien_list, + filter_bl=bundesland, + filter_wp=wahlperiode, + exclude_antragsteller=exclude_antragsteller, + min_n_per_bucket=min_n_per_bucket, + ) + + @app.get("/api/auswertungen/stimm-index-pro-gruppe") async def auswertungen_stimm_index_pro_gruppe( bundesland: Optional[str] = None, diff --git a/app/templates/v2/screens/auswertungen.html b/app/templates/v2/screens/auswertungen.html index c259546..d192cc1 100644 --- a/app/templates/v2/screens/auswertungen.html +++ b/app/templates/v2/screens/auswertungen.html @@ -291,9 +291,23 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
- +

4. Empfehlungs-Konsistenz (gegen GWÖ-Empfehlung)

+ margin:1.5rem 0 0.5rem;">4. Über-Zeit-Drift (Quartal × Fraktion) +

+ Stimm-Index pro Fraktion über die Quartale der laufenden Wahlperiode. + Pro Fraktion eine Linie. Lücken in Quartalen mit zu wenig Daten (n < 3 + pro Vote-Richtung). Macht sichtbar, ob sich die Gemeinwohl-Affinität + einer Fraktion verschiebt. +

+
+ +
+
+ + +

5. Empfehlungs-Konsistenz (gegen GWÖ-Empfehlung)

Anteil der Anträge mit GWÖ-Empfehlung „Uneingeschränkt unterstützen" oder „Unterstützen mit Änderungen", bei denen die Fraktion trotzdem @@ -305,9 +319,9 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }

- +

5. Stimm-Index pro Bundesland (Cross-BL)

+ margin:1.5rem 0 0.5rem;">6. Stimm-Index pro Bundesland (Cross-BL)

Gleiche Fraktion in mehreren Ländern. Macht regionale Drift sichtbar — gleiche Partei, unterschiedlicher Stimm-Index? Nur Fraktionen in ≥2 BL @@ -351,7 +365,7 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; } {% block body_scripts %}