|
|
|
|
@ -245,6 +245,81 @@ def load_active_mps(db_path: str) -> set: |
|
|
|
|
return set() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compute_party_discipline( |
|
|
|
|
db_path: str, |
|
|
|
|
start_date: str, |
|
|
|
|
end_date: str, |
|
|
|
|
) -> pd.DataFrame: |
|
|
|
|
"""Compute per-party voting discipline (Rice index) for roll-call votes in a date range. |
|
|
|
|
|
|
|
|
|
Only individual MP vote rows are used (mp_name LIKE '%,%'). |
|
|
|
|
Returns a DataFrame with columns [party, n_motions, discipline] sorted by discipline ascending. |
|
|
|
|
Returns an empty DataFrame if fewer than 1 qualifying motion exists or on any DB error. |
|
|
|
|
|
|
|
|
|
Rice index per motion per party = fraction of party MPs voting with the party majority. |
|
|
|
|
The per-party score is the average Rice index across all motions in the date range. |
|
|
|
|
""" |
|
|
|
|
try: |
|
|
|
|
conn = duckdb.connect(db_path, read_only=True) |
|
|
|
|
result = conn.execute( |
|
|
|
|
""" |
|
|
|
|
WITH individual_votes AS ( |
|
|
|
|
SELECT |
|
|
|
|
motion_id, |
|
|
|
|
party, |
|
|
|
|
LOWER(vote) AS vote |
|
|
|
|
FROM mp_votes |
|
|
|
|
WHERE mp_name LIKE '%,%' |
|
|
|
|
AND date >= CAST(? AS DATE) |
|
|
|
|
AND date <= CAST(? AS DATE) |
|
|
|
|
AND vote IN ('voor', 'tegen', 'afwezig', 'onthouden') |
|
|
|
|
), |
|
|
|
|
vote_counts AS ( |
|
|
|
|
SELECT |
|
|
|
|
motion_id, |
|
|
|
|
party, |
|
|
|
|
vote, |
|
|
|
|
COUNT(*) AS cnt |
|
|
|
|
FROM individual_votes |
|
|
|
|
GROUP BY motion_id, party, vote |
|
|
|
|
), |
|
|
|
|
majority_vote AS ( |
|
|
|
|
SELECT |
|
|
|
|
motion_id, |
|
|
|
|
party, |
|
|
|
|
FIRST(vote ORDER BY cnt DESC, vote ASC) AS maj_vote, |
|
|
|
|
SUM(cnt) AS total_mp_votes |
|
|
|
|
FROM vote_counts |
|
|
|
|
GROUP BY motion_id, party |
|
|
|
|
), |
|
|
|
|
rice_per_motion AS ( |
|
|
|
|
SELECT |
|
|
|
|
mv.motion_id, |
|
|
|
|
mv.party, |
|
|
|
|
SUM(CASE WHEN vc.vote = mv.maj_vote THEN vc.cnt ELSE 0 END) |
|
|
|
|
* 1.0 / mv.total_mp_votes AS rice |
|
|
|
|
FROM majority_vote mv |
|
|
|
|
JOIN vote_counts vc |
|
|
|
|
ON mv.motion_id = vc.motion_id AND mv.party = vc.party |
|
|
|
|
GROUP BY mv.motion_id, mv.party, mv.total_mp_votes |
|
|
|
|
) |
|
|
|
|
SELECT |
|
|
|
|
party, |
|
|
|
|
COUNT(DISTINCT motion_id) AS n_motions, |
|
|
|
|
AVG(rice) AS discipline |
|
|
|
|
FROM rice_per_motion |
|
|
|
|
GROUP BY party |
|
|
|
|
ORDER BY discipline ASC |
|
|
|
|
""", |
|
|
|
|
[start_date, end_date], |
|
|
|
|
).fetchdf() |
|
|
|
|
conn.close() |
|
|
|
|
return result |
|
|
|
|
except Exception as exc: |
|
|
|
|
logger.warning("compute_party_discipline failed: %s", exc) |
|
|
|
|
return pd.DataFrame(columns=["party", "n_motions", "discipline"]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@st.cache_data(show_spinner="Partijposities op SVD-assen laden…") |
|
|
|
|
def load_party_axis_scores(db_path: str) -> Dict[str, List[float]]: |
|
|
|
|
"""Return per-party SVD vectors, computed as mean of individual MP vectors. |
|
|
|
|
@ -704,6 +779,26 @@ def _add_y_direction_annotations(fig: go.Figure) -> None: |
|
|
|
|
fig.add_annotation(**common, y=-0.06, text="▼ Conservatief", xanchor="center") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _window_to_dates(window_id: str) -> tuple[str, str]: |
|
|
|
|
"""Return (start_date, end_date) ISO strings for a given window_id. |
|
|
|
|
|
|
|
|
|
Annual windows like '2024' → ('2024-01-01', '2024-12-31'). |
|
|
|
|
'current_parliament' → ('2023-11-22', '2099-12-31') (2023 formation date, open end). |
|
|
|
|
Unknown formats → ('2000-01-01', '2099-12-31') (effectively all time). |
|
|
|
|
""" |
|
|
|
|
if window_id == "current_parliament": |
|
|
|
|
return ("2023-11-22", "2099-12-31") |
|
|
|
|
if re.fullmatch(r"\d{4}", window_id): |
|
|
|
|
return (f"{window_id}-01-01", f"{window_id}-12-31") |
|
|
|
|
m = re.fullmatch(r"(\d{4})-Q([1-4])", window_id) |
|
|
|
|
if m: |
|
|
|
|
year, q = int(m.group(1)), int(m.group(2)) |
|
|
|
|
starts = {1: "01-01", 2: "04-01", 3: "07-01", 4: "10-01"} |
|
|
|
|
ends = {1: "03-31", 2: "06-30", 3: "09-30", 4: "12-31"} |
|
|
|
|
return (f"{year}-{starts[q]}", f"{year}-{ends[q]}") |
|
|
|
|
return ("2000-01-01", "2099-12-31") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_compass_tab(db_path: str, window_size: str) -> None: |
|
|
|
|
st.subheader("Politiek Kompas") |
|
|
|
|
st.markdown( |
|
|
|
|
@ -854,6 +949,81 @@ def build_compass_tab(db_path: str, window_size: str) -> None: |
|
|
|
|
with col1: |
|
|
|
|
st.plotly_chart(fig, use_container_width=True) |
|
|
|
|
|
|
|
|
|
# --- Voting discipline section --- |
|
|
|
|
_MIN_MOTIONS_FOR_DISCIPLINE = 5 |
|
|
|
|
start_date, end_date = _window_to_dates(window_idx) |
|
|
|
|
disc_df = compute_party_discipline(db_path, start_date, end_date) |
|
|
|
|
|
|
|
|
|
st.subheader("Stemgedrag cohesie") |
|
|
|
|
if disc_df.empty or disc_df["n_motions"].max() < _MIN_MOTIONS_FOR_DISCIPLINE: |
|
|
|
|
st.caption( |
|
|
|
|
"Te weinig hoofdelijke stemmingen in dit venster voor een cohesieanalyse." |
|
|
|
|
) |
|
|
|
|
else: |
|
|
|
|
compass_parties = set(df_pos["party"].unique()) |
|
|
|
|
disc_df = disc_df[disc_df["party"].isin(compass_parties)].copy() |
|
|
|
|
|
|
|
|
|
if disc_df.empty: |
|
|
|
|
st.caption("Geen overlappende partijen tussen kompas en stemmingsdata.") |
|
|
|
|
else: |
|
|
|
|
disc_df["discipline_pct"] = (disc_df["discipline"] * 100).round(1) |
|
|
|
|
disc_df["party_label"] = disc_df.apply( |
|
|
|
|
lambda r: f"{r['party']} ({int(r['n_motions'])} moties)", axis=1 |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
bar_fig = px.bar( |
|
|
|
|
disc_df.sort_values("discipline"), |
|
|
|
|
x="discipline_pct", |
|
|
|
|
y="party_label", |
|
|
|
|
orientation="h", |
|
|
|
|
color="discipline_pct", |
|
|
|
|
color_continuous_scale="RdYlGn", |
|
|
|
|
range_color=[80, 100], |
|
|
|
|
labels={"discipline_pct": "Cohesie (%)", "party_label": "Partij"}, |
|
|
|
|
title="Cohesie bij hoofdelijke stemmingen", |
|
|
|
|
) |
|
|
|
|
bar_fig.update_layout( |
|
|
|
|
height=max(300, len(disc_df) * 35 + 80), |
|
|
|
|
showlegend=False, |
|
|
|
|
coloraxis_showscale=False, |
|
|
|
|
yaxis_title="", |
|
|
|
|
) |
|
|
|
|
st.plotly_chart(bar_fig, use_container_width=True) |
|
|
|
|
|
|
|
|
|
top3 = disc_df.nlargest(3, "discipline")[ |
|
|
|
|
["party", "discipline_pct", "n_motions"] |
|
|
|
|
] |
|
|
|
|
bot3 = disc_df.nsmallest(3, "discipline")[ |
|
|
|
|
["party", "discipline_pct", "n_motions"] |
|
|
|
|
] |
|
|
|
|
col_a, col_b = st.columns(2) |
|
|
|
|
with col_a: |
|
|
|
|
st.markdown("**Meest eensgezind**") |
|
|
|
|
st.dataframe( |
|
|
|
|
top3.rename( |
|
|
|
|
columns={ |
|
|
|
|
"party": "Partij", |
|
|
|
|
"discipline_pct": "Cohesie (%)", |
|
|
|
|
"n_motions": "Moties", |
|
|
|
|
} |
|
|
|
|
), |
|
|
|
|
hide_index=True, |
|
|
|
|
use_container_width=True, |
|
|
|
|
) |
|
|
|
|
with col_b: |
|
|
|
|
st.markdown("**Meest verdeeld**") |
|
|
|
|
st.dataframe( |
|
|
|
|
bot3.rename( |
|
|
|
|
columns={ |
|
|
|
|
"party": "Partij", |
|
|
|
|
"discipline_pct": "Cohesie (%)", |
|
|
|
|
"n_motions": "Moties", |
|
|
|
|
} |
|
|
|
|
), |
|
|
|
|
hide_index=True, |
|
|
|
|
use_container_width=True, |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- |
|
|
|
|
# Tab 2: Partij Trajectories |
|
|
|
|
|