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:
parent
79003d6056
commit
1e381d23ab
@ -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,
|
||||
|
||||
21
app/main.py
21
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,
|
||||
|
||||
@ -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 < 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`;
|
||||
|
||||
@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user