feat(#168): Über-Zeit-Drift im Stimmverhalten-Tab

Stimm-Index pro Fraktion über Quartale. Linien-Chart pro Fraktion,
Lücken bei Quartalen mit n<3 (Ja UND Nein). Macht sichtbar, ob sich die
Gemeinwohl-Affinität einer Fraktion innerhalb der Wahlperiode verschiebt.

- `_quarter_for(datum)` Helper: ISO-Datum → "YYYY-Qn".
- `aggregate_stimm_index_zeitreihe()` analog zu pro_wert/pro_gruppe,
  aber nach Quartal-Bucket statt Achse.
- `GET /api/auswertungen/stimm-index-zeitreihe?parteien=CDU,SPD,...`
- 4. Sub-Section im Stimmverhalten-Tab: Multi-Linien-Chart mit
  Partei-Farben (CDU schwarz, SPD rot, GRÜNE grün, FDP gelb, AfD blau,
  LINKE pink, BSW lila, SSW navy, BVB-FW orange).

Bei aktueller Sparse-Datenmenge (35 Assessments × 4 Quartale) ist der
Chart heute meist leer — Infrastruktur ist ready, fuellt sich automatisch
mit Issue #44 Batch-Bewertung.

Tests: 10 neue (4 _quarter_for, 6 aggregate). Suite jetzt 1005 grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-29 23:03:53 +02:00
parent 79003d6056
commit 1e381d23ab
4 changed files with 285 additions and 5 deletions

View File

@ -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,

View File

@ -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,

View File

@ -291,9 +291,23 @@ table.modal-table th { background: var(--ecg-bg-subtle); font-weight: 700; }
</div>
<div id="sv-wert-heatmap" class="matrix-wrap"></div>
<!-- Sub 4: Empfehlungs-Konsistenz Bar Chart -->
<!-- Sub 3b: Über-Zeit-Drift Line Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">4. Empfehlungs-Konsistenz (gegen GWÖ-Empfehlung)</h3>
margin:1.5rem 0 0.5rem;">4. Über-Zeit-Drift (Quartal × Fraktion)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
Stimm-Index pro Fraktion über die Quartale der laufenden Wahlperiode.
Pro Fraktion eine Linie. Lücken in Quartalen mit zu wenig Daten (n &lt; 3
pro Vote-Richtung). Macht sichtbar, ob sich die Gemeinwohl-Affinität
einer Fraktion verschiebt.
</p>
<div class="matrix-wrap" style="padding:14px;">
<canvas id="sv-zeitreihe-chart" style="max-height:400px;"></canvas>
</div>
<div id="sv-zeitreihe-meta" class="meta-line"></div>
<!-- Sub 5: Empfehlungs-Konsistenz Bar Chart -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">5. Empfehlungs-Konsistenz (gegen GWÖ-Empfehlung)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
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; }
</div>
<div id="sv-empfehlung-meta" class="meta-line"></div>
<!-- Sub 5: Cross-BL Grouped Bar -->
<!-- Sub 6: Cross-BL Grouped Bar -->
<h3 style="font-family:var(--font-display);font-size:15px;color:var(--ecg-teal);
margin:1.5rem 0 0.5rem;">5. Stimm-Index pro Bundesland (Cross-BL)</h3>
margin:1.5rem 0 0.5rem;">6. Stimm-Index pro Bundesland (Cross-BL)</h3>
<p style="font-size:11px;font-family:var(--font-mono);opacity:0.7;margin:0 0 0.5rem;">
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 %}
<script>
let _tabLoaded = { 'bl-partei': false, 'themen': false, 'stimmverhalten': false };
let _svCharts = { index: null, heuchelei: null, empfehlung: null, crossBl: null };
let _svCharts = { index: null, heuchelei: null, empfehlung: null, crossBl: null, zeitreihe: null };
let _svMatrixAxis = 'werte'; // 'werte' or 'gruppen'
function setMatrixAxis(axis) {
@ -588,6 +602,7 @@ async function loadStimmverhalten() {
loadStimmIndex(bl, exclude);
loadHeuchelei(bl);
loadMatrixHeatmap();
loadStimmIndexZeitreihe(bl, exclude);
loadEmpfehlungsKonsistenz(bl);
loadStimmIndexCrossBl(exclude);
}
@ -787,6 +802,83 @@ async function loadStimmIndexProWert(bl, exclude) {
}
}
const SV_PARTEI_COLORS = {
'CDU': '#1a1a1a', 'CSU': '#1a1a1a',
'SPD': '#e3000f',
'GRÜNE': '#46962b',
'FDP': '#ffed00',
'AfD': '#0489db',
'LINKE': '#be3075',
'BSW': '#7d1f8a',
'SSW': '#003d8f',
'BVB-FW': '#f7941d',
'FREIE WÄHLER': '#f7941d',
};
function svParteiColor(p) { return SV_PARTEI_COLORS[p] || '#888'; }
async function loadStimmIndexZeitreihe(bl, exclude) {
const meta = document.getElementById('sv-zeitreihe-meta');
let url = `/api/auswertungen/stimm-index-zeitreihe?exclude_antragsteller=${exclude}&min_n_per_bucket=3`;
if (bl) url += '&bundesland=' + encodeURIComponent(bl);
try {
const r = await fetch(url);
const data = await r.json();
if (_svCharts.zeitreihe) _svCharts.zeitreihe.destroy();
const fraktionen = data.fraktionen.filter(p => {
// Mindestens 1 Bucket mit ausreichend Daten
return data.buckets.some(b => data.detail[p][b].ausreichend);
});
if (!fraktionen.length || !data.buckets.length) {
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge — kein Quartal erreicht das Mindest-N (3 Ja UND 3 Nein pro Fraktion).`;
return;
}
const datasets = fraktionen.map(p => ({
label: p,
data: data.series[p],
borderColor: svParteiColor(p),
backgroundColor: svParteiColor(p) + '33',
fill: false,
spanGaps: true,
tension: 0.2,
pointRadius: 4,
}));
const ctx = document.getElementById('sv-zeitreihe-chart');
_svCharts.zeitreihe = new Chart(ctx, {
type: 'line',
data: { labels: data.buckets, datasets: datasets },
options: {
responsive: true,
scales: {
y: { min: -10, max: 10, title: { display: true, text: 'Stimm-Index (10..+10)' } },
x: { title: { display: true, text: 'Quartal' } },
},
plugins: {
tooltip: {
callbacks: {
afterLabel: (ctx) => {
const p = ctx.dataset.label;
const b = data.buckets[ctx.dataIndex];
const d = data.detail[p][b];
return `n_ja=${d.n_ja}, n_nein=${d.n_nein}`;
}
}
}
}
}
});
meta.textContent = `Datenbasis: ${data.n_assessments_matched} Anträge über ${data.buckets.length} Quartale. Mindest-N pro Bucket: 3 Ja UND 3 Nein.`;
} catch (e) {
meta.textContent = 'Fehler: ' + e;
}
}
async function loadStimmIndexProGruppe(bl, exclude) {
const wrap = document.getElementById('sv-wert-heatmap');
let url = `/api/auswertungen/stimm-index-pro-gruppe?exclude_antragsteller=${exclude}&min_n=3`;

View File

@ -19,8 +19,10 @@ from app.auswertungen import (
aggregate_stimm_index_cross_bl,
aggregate_stimm_index_pro_gruppe,
aggregate_stimm_index_pro_wert,
aggregate_stimm_index_zeitreihe,
export_stimmverhalten_csv,
_gruppen_score_for_assessment,
_quarter_for,
_wert_score_for_assessment,
)
@ -438,6 +440,71 @@ class TestAggregateProGruppe:
assert set(wert_out["werte"]).isdisjoint(set(gruppe_out["gruppen"]))
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_zeitreihe (#168)
# ─────────────────────────────────────────────────────────────────────────────
class TestQuarterFor:
def test_q1(self):
assert _quarter_for("2024-01-15") == "2024-Q1"
assert _quarter_for("2024-03-31") == "2024-Q1"
def test_q2(self):
assert _quarter_for("2024-04-01") == "2024-Q2"
assert _quarter_for("2024-06-30") == "2024-Q2"
def test_q3_q4(self):
assert _quarter_for("2024-07-15") == "2024-Q3"
assert _quarter_for("2024-12-31") == "2024-Q4"
def test_invalid(self):
assert _quarter_for("") is None
assert _quarter_for("garbage") is None
assert _quarter_for("2024-13-01") is None # invalid month
class TestAggregateZeitreihe:
def test_structure(self, sample_db):
out = aggregate_stimm_index_zeitreihe(db_path=sample_db, min_n_per_bucket=1)
assert "buckets" in out
assert "fraktionen" in out
assert "series" in out
assert "detail" in out
assert "n_assessments_matched" in out
def test_buckets_sorted(self, sample_db):
out = aggregate_stimm_index_zeitreihe(db_path=sample_db, min_n_per_bucket=1)
assert out["buckets"] == sorted(out["buckets"])
def test_series_alignment(self, sample_db):
"""Pro Fraktion: series-Liste muss exakt so lang sein wie buckets."""
out = aggregate_stimm_index_zeitreihe(db_path=sample_db, min_n_per_bucket=1)
for partei in out["fraktionen"]:
assert len(out["series"][partei]) == len(out["buckets"])
def test_min_n_per_bucket(self, sample_db):
"""Mit hohem min_n_per_bucket wird stimm_index meist None."""
out = aggregate_stimm_index_zeitreihe(
db_path=sample_db, min_n_per_bucket=100,
)
for partei in out["fraktionen"]:
for idx in out["series"][partei]:
assert idx is None
def test_filter_by_parteien(self, sample_db):
"""Wenn parteien-Filter gesetzt, nur diese in fraktionen."""
out = aggregate_stimm_index_zeitreihe(
db_path=sample_db, parteien=["AfD"], min_n_per_bucket=1,
)
assert out["fraktionen"] == ["AfD"]
def test_empty_db(self, tmp_path):
out = aggregate_stimm_index_zeitreihe(db_path=tmp_path / "missing.db")
assert out["buckets"] == []
assert out["fraktionen"] == []
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_cross_bl
# ─────────────────────────────────────────────────────────────────────────────