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:
@@ -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 = {"Verzorgingsstaat–Marktwerking", "Links–Rechts"}
|
||||
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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user