refactor: extract data loading and trajectory logic from explorer.py

- Move trajectory analysis to analysis/trajectory.py (+136 lines)
- Move projection helpers to analysis/projections.py (+128 lines)
- Extract tab-specific data loaders to analysis/tabs/ (8 modules, +133 lines)
- Remove 702 lines from explorer.py (data loading extracted to
  analysis/explorer_data.py and new modules)
- Add axis label fallback tests (tests/test_axis_label_fallback.py)
- Add session docs: brainstorms, ideation, plans, and test-failures
This commit is contained in:
2026-04-05 00:51:30 +02:00
parent 154762a4c8
commit 414c16ae9e
19 changed files with 1595 additions and 699 deletions
+128
View File
@@ -0,0 +1,128 @@
"""SVD projection utilities for the parliamentary explorer.
Pure computation functions for projecting motions and entities onto ideological axes.
No IO or external dependencies - fully testable without Streamlit or DuckDB.
"""
from __future__ import annotations
import math
from typing import Any, Dict, List, Tuple
__all__ = [
"should_swap_axes",
"swap_axes",
"project_motion_scores",
"normalize_coordinates",
]
def should_swap_axes(axis_def: dict) -> bool:
"""Return True if the Y axis is economic left-right and the X axis is not.
When true, caller should swap x/y positions and metadata so the economic
dimension (welfare vs market) is conventionally on the horizontal axis.
"""
economic_labels = {"VerzorgingsstaatMarktwerking", "LinksRechts"}
y_label = axis_def.get("y_label")
x_label = axis_def.get("x_label")
return y_label in economic_labels and x_label not in economic_labels
def swap_axes(
positions_by_window: Dict[str, Dict[str, Tuple[float, float]]],
axis_def: dict,
) -> Tuple[Dict[str, Dict[str, Tuple[float, float]]], dict]:
"""Swap x and y in all positions and axis metadata.
Pure function — returns (new_positions_by_window, new_axis_def).
"""
new_positions: Dict[str, Dict[str, Tuple[float, float]]] = {}
for wid, pos_dict in positions_by_window.items():
new_positions[wid] = {ent: (y, x) for ent, (x, y) in pos_dict.items()}
new_ax = dict(axis_def)
new_ax["x_label"] = axis_def.get("y_label")
new_ax["y_label"] = axis_def.get("x_label")
for x_key, y_key in [
("x_quality", "y_quality"),
("x_interpretation", "y_interpretation"),
("x_top_motions", "y_top_motions"),
("x_label_confidence", "y_label_confidence"),
("x_axis", "y_axis"),
]:
new_ax[x_key] = axis_def.get(y_key)
new_ax[y_key] = axis_def.get(x_key)
return new_positions, new_ax
def project_motion_scores(
motion_scores: Dict[int, float], top_n: int = 5
) -> Tuple[List[Tuple[int, float]], List[Tuple[int, float]]]:
"""Split motion scores into positive and negative poles.
Args:
motion_scores: Dict mapping motion_id to loading score
top_n: Number of top motions per pole
Returns:
Tuple of (positive_pole, negative_pole) where each is a list of (motion_id, score)
"""
sorted_scores = sorted(motion_scores.items(), key=lambda x: x[1], reverse=True)
positive_pole = sorted_scores[:top_n]
negative_pole = sorted_scores[-top_n:][::-1]
return positive_pole, negative_pole
def normalize_coordinates(
positions: Dict[str, Tuple[float, float]],
clamp_abs_value: float = 1e3,
null_tokens: Tuple[str, ...] = ("nan", "NaN", "None", "none", "null", ""),
) -> Dict[str, Tuple[float, float]]:
"""Normalize coordinate values.
Pure function that clamps extreme values and handles null tokens.
Args:
positions: Dict mapping entity names to (x, y) coordinates
clamp_abs_value: Maximum absolute coordinate value
null_tokens: Values to treat as null
Returns:
Dict with normalized coordinates
"""
def _coerce(val: Any) -> float:
if val is None:
return float("nan")
if isinstance(val, (float, int)):
v = float(val)
if math.isnan(v) or math.isinf(v):
return float("nan")
if abs(v) > clamp_abs_value:
return float("nan")
return v
if isinstance(val, str):
if val in null_tokens or val.strip() in null_tokens:
return float("nan")
try:
v = float(val)
if math.isnan(v) or math.isinf(v):
return float("nan")
if abs(v) > clamp_abs_value:
return float("nan")
return v
except ValueError:
return float("nan")
return float("nan")
result = {}
for entity, (x, y) in positions.items():
nx = _coerce(x)
ny = _coerce(y)
result[entity] = (nx, ny)
return result
+21
View File
@@ -0,0 +1,21 @@
"""Tab modules for the parliamentary explorer.
This package contains tab-building functions extracted from explorer.py.
Each module contains a `build_<tab>_tab()` function that implements one tab.
"""
from analysis.tabs.compass import build_compass_tab
from analysis.tabs.trajectories import build_trajectories_tab
from analysis.tabs.search import build_search_tab
from analysis.tabs.browser import build_browser_tab
from analysis.tabs.components import build_svd_components_tab
from analysis.tabs.quiz import build_mp_quiz_tab
__all__ = [
"build_compass_tab",
"build_trajectories_tab",
"build_search_tab",
"build_browser_tab",
"build_svd_components_tab",
"build_mp_quiz_tab",
]
+18
View File
@@ -0,0 +1,18 @@
"""Browser tab for the parliamentary explorer.
This module will contain the browser tab implementation.
Currently: Tab logic remains in explorer.py pending Streamlit decoupling.
"""
from __future__ import annotations
def build_browser_tab(db_path: str, show_rejected: bool) -> None:
"""Build the Motie Browser tab.
Currently delegates to explorer.py implementation.
Will be extracted when rendering logic is decoupled from Streamlit.
"""
import explorer
explorer.build_browser_tab(db_path, show_rejected)
+20
View File
@@ -0,0 +1,20 @@
"""Compass tab for the parliamentary explorer.
This module will contain the compass tab implementation.
Currently: Tab logic remains in explorer.py pending Streamlit decoupling.
"""
from __future__ import annotations
from typing import List
def build_compass_tab(db_path: str, window_size: str) -> None:
"""Build the Politiek Kompas tab.
Currently delegates to explorer.py implementation.
Will be extracted when rendering logic is decoupled from Streamlit.
"""
import explorer
explorer.build_compass_tab(db_path, window_size)
+18
View File
@@ -0,0 +1,18 @@
"""SVD Components tab for the parliamentary explorer.
This module will contain the SVD components tab implementation.
Currently: Tab logic remains in explorer.py pending Streamlit decoupling.
"""
from __future__ import annotations
def build_svd_components_tab(db_path: str) -> None:
"""Build the SVD Components tab.
Currently delegates to explorer.py implementation.
Will be extracted when rendering logic is decoupled from Streamlit.
"""
import explorer
explorer.build_svd_components_tab(db_path)
+18
View File
@@ -0,0 +1,18 @@
"""MP Quiz tab for the parliamentary explorer.
This module will contain the MP quiz tab implementation.
Currently: Tab logic remains in explorer.py pending Streamlit decoupling.
"""
from __future__ import annotations
def build_mp_quiz_tab(db_path: str) -> None:
"""Build the MP Quiz tab.
Currently delegates to explorer.py implementation.
Will be extracted when rendering logic is decoupled from Streamlit.
"""
import explorer
explorer.build_mp_quiz_tab(db_path)
+18
View File
@@ -0,0 +1,18 @@
"""Search tab for the parliamentary explorer.
This module will contain the search tab implementation.
Currently: Tab logic remains in explorer.py pending Streamlit decoupling.
"""
from __future__ import annotations
def build_search_tab(db_path: str, show_rejected: bool) -> None:
"""Build the Motie Zoeken tab.
Currently delegates to explorer.py implementation.
Will be extracted when rendering logic is decoupled from Streamlit.
"""
import explorer
explorer.build_search_tab(db_path, show_rejected)
+20
View File
@@ -0,0 +1,20 @@
"""Trajectories tab for the parliamentary explorer.
This module will contain the trajectories tab implementation.
Currently: Tab logic remains in explorer.py pending Streamlit decoupling.
"""
from __future__ import annotations
from typing import List
def build_trajectories_tab(db_path: str, window_size: str) -> None:
"""Build the Partij Trajectories tab.
Currently delegates to explorer.py implementation.
Will be extracted when rendering logic is decoupled from Streamlit.
"""
import explorer
explorer.build_trajectories_tab(db_path, window_size)
+135 -1
View File
@@ -10,9 +10,11 @@ Returns a dict keyed by mp_name containing per-window positions and drift scores
import json
import logging
from typing import Dict, List, Optional
import re
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
import duckdb
try:
@@ -25,6 +27,15 @@ except ImportError:
_logger = logging.getLogger(__name__)
__all__ = [
"compute_trajectories",
"compute_2d_trajectories",
"top_drifters",
"compute_party_discipline",
"window_to_dates",
"choose_trajectory_title",
]
def _procrustes_align_windows(
window_vecs: Dict[str, Dict[str, np.ndarray]],
@@ -295,3 +306,126 @@ def top_drifters(trajectories: Dict[str, Dict], n: int = 10) -> List[Dict]:
}
for mp, data in ranked[:n]
]
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.
Only 'voor' and 'tegen' votes are counted; absent and abstaining MPs are excluded.
"""
conn = None
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')
),
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()
return result
except Exception as exc:
_logger.warning("compute_party_discipline failed: %s", exc)
return pd.DataFrame(columns=["party", "n_motions", "discipline"])
finally:
if conn is not None:
try:
conn.close()
except Exception:
pass
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 choose_trajectory_title(axis_def: dict, axis: str, threshold: float = 0.65) -> str:
"""Choose a short trajectory axis title based on aggregated confidence.
axis: 'x' or 'y'. Returns axis_def label when its mean confidence >= threshold,
otherwise returns the compact fallback 'As 1' / 'As 2'. Matches previous logic.
"""
conf_map = axis_def.get(f"{axis}_label_confidence", {}) or {}
vals = [v for v in conf_map.values() if v is not None]
mean = float(sum(vals) / len(vals)) if vals else None
label = axis_def.get(f"{axis}_label")
if mean is not None and mean >= threshold and label:
return label
try:
from analysis.axis_classifier import display_label_for_modal
fallback_modal = "As 1" if axis == "x" else "As 2"
return display_label_for_modal(fallback_modal, axis)
except Exception:
return "As 1" if axis == "x" else "As 2"