diff --git a/explorer.py b/explorer.py index 30ef6f3..89fb348 100644 --- a/explorer.py +++ b/explorer.py @@ -481,7 +481,6 @@ def load_positions( """ from analysis.political_axis import compute_2d_axes - # Use only annual windows (quarterly windows are excluded by get_uniform_dim_windows). all_available = get_uniform_dim_windows(db_path) if not all_available: @@ -539,6 +538,56 @@ def load_active_mps(db_path: str) -> set: return explorer_data.load_active_mps(db_path) +def get_aligned_party_scores( + db_path: str, window: str, active_mps: set | None = None +) -> Dict[str, np.ndarray]: + """Get party scores for all N components from aligned PCA positions. + + For current_parliament, pass active_mps to filter to only seated MPs + (matching the compass behaviour). Historical windows include all MPs. + + Args: + db_path: Path to DuckDB database + window: Window identifier (e.g. 'current_parliament', '2025') + active_mps: Set of active MP names to filter current_parliament by. + Required when window is 'current_parliament' to match compass. + """ + from analysis.political_axis import compute_nd_axes + + annual_windows = get_uniform_dim_windows(db_path) + scores_by_window, _ = compute_nd_axes( + db_path, window_ids=annual_windows, n_components=10 + ) + window_scores = scores_by_window.get(window, {}) + if not window_scores: + return {} + + # For current_parliament, filter to active MPs (still seated) to match compass. + # Historical windows include all MPs active at the time — no restriction needed. + if window == "current_parliament" and active_mps is not None: + window_scores = {mp: sc for mp, sc in window_scores.items() if mp in active_mps} + + # Load party map to convert MP names to parties + _party_map = load_party_map(db_path) + + # Aggregate MP scores to party centroids per component + n_comps = 10 + party_scores_agg: Dict[str, List[np.ndarray]] = {} + for mp_name, scores in window_scores.items(): + party = _party_map.get( + mp_name, _party_map.get(mp_name.split("(")[0].strip(), None) + ) + if party: + party_scores_agg.setdefault(party, []).append(scores[:n_comps]) + + # Compute mean scores per party for each component + return { + party: np.mean(np.vstack(score_list), axis=0) + for party, score_list in party_scores_agg.items() + if score_list + } + + def compute_party_discipline( db_path: str, start_date: str, @@ -2640,35 +2689,10 @@ def build_svd_components_tab(db_path: str) -> None: # This ensures consistency between compass and SVD components tab. def _get_aligned_party_scores(window: str) -> Dict[str, np.ndarray]: """Get party scores for all N components from aligned PCA positions.""" - from analysis.political_axis import compute_nd_axes - - annual_windows = get_uniform_dim_windows(db_path) - scores_by_window, _ = compute_nd_axes( - db_path, window_ids=annual_windows, n_components=10 + active_mps = ( + load_active_mps(db_path) if window == "current_parliament" else None ) - window_scores = scores_by_window.get(window, {}) - if not window_scores: - return {} - - # Load party map to convert MP names to parties - _party_map = load_party_map(db_path) - - # Aggregate MP scores to party centroids per component - n_comps = 10 - party_scores_agg: Dict[str, List[np.ndarray]] = {} - for mp_name, scores in window_scores.items(): - party = _party_map.get( - mp_name, _party_map.get(mp_name.split("(")[0].strip(), None) - ) - if party: - party_scores_agg.setdefault(party, []).append(scores[:n_comps]) - - # Compute mean scores per party for each component - return { - party: np.mean(np.vstack(score_list), axis=0) - for party, score_list in party_scores_agg.items() - if score_list - } + return get_aligned_party_scores(db_path, window, active_mps) # Extract 1D scores for this component using Procrustes-aligned PCA scores. # All 10 components use _get_aligned_party_scores (compute_nd_axes with annual-only