Spaces:
Runtime error
Runtime error
| """ | |
| Streamlit app: simulate voting scenarios for "Your Party" in Oxford East, | |
| with additional static scenario charts (two univariate plots and one surface/heatmap) | |
| showing outcomes as percent-left and percent-right switching values vary. | |
| Usage: | |
| pip install streamlit plotly pandas numpy | |
| streamlit run this_file.py | |
| """ | |
| import streamlit as st | |
| import pandas as pd | |
| import numpy as np | |
| import plotly.graph_objects as go | |
| # -------------------------- | |
| # Fixed inputs (from user) | |
| # -------------------------- | |
| E = 71845 # Electorate | |
| T = 54.8 # Historic turnout percent | |
| P_L = 31.1 # Labour percent | |
| P_D = 8.8 # LibDem percent | |
| P_C = 7.1 # Conservative percent | |
| P_R = 17.5 # Reform percent | |
| P_G = 24.0 # Green percent | |
| PARTY_COLORS = { | |
| "Labour": "#E4003B", | |
| "LibDem": "#FAA61A", | |
| "Conservative": "#0087DC", | |
| "Reform": "#7F3FBF", | |
| "Green": "#78AB46", | |
| "Your Party": "#FFFFFF", | |
| # for plotting aggregated "Right party" / "Other" states in scenario maps | |
| "RightWinner": "#0087DC", | |
| "Other": "#999999", | |
| } | |
| st.set_page_config(layout="wide", page_title="Oxford East: Your Party simulator") | |
| st.title("Oxford East — Your Party voting simulator") | |
| # -------------------------- | |
| # Derived baseline numbers | |
| # -------------------------- | |
| historic_voters = E * (T / 100.0) | |
| non_voters_pool = E - historic_voters | |
| baseline = { | |
| "Labour": historic_voters * (P_L / 100.0), | |
| "LibDem": historic_voters * (P_D / 100.0), | |
| "Conservative": historic_voters * (P_C / 100.0), | |
| "Reform": historic_voters * (P_R / 100.0), | |
| "Green": historic_voters * (P_G / 100.0), | |
| "Your Party": 0.0, | |
| } | |
| # -------------------------- | |
| # User-controllable variables | |
| # -------------------------- | |
| st.sidebar.header("Simulation inputs") | |
| max_non_voters = int(non_voters_pool) | |
| non_voters_convinced = st.sidebar.number_input( | |
| "Non-voters convinced to vote for Your Party (absolute number)", | |
| min_value=0, | |
| max_value=max_non_voters, | |
| value=0, | |
| step=1, | |
| ) | |
| percent_left_switch = st.sidebar.slider( | |
| "Percent of left voters who switch to Your Party", | |
| min_value=0.0, | |
| max_value=100.0, | |
| value=0.0, | |
| step=0.1, | |
| ) | |
| percent_right_switch = st.sidebar.slider( | |
| "Percent of right voters who switch to Your Party", | |
| min_value=0.0, | |
| max_value=100.0, | |
| value=0.0, | |
| step=0.1, | |
| ) | |
| st.subheader("Variables in this simulation") | |
| st.write(f"- Non-voters convinced to vote for Your Party: {non_voters_convinced:,}") | |
| st.write(f"- Percent of left voters convinced (from left pool): {percent_left_switch:.1f}%") | |
| st.write(f"- Percent of right voters convinced (from right pool): {percent_right_switch:.1f}%") | |
| # -------------------------- | |
| # Helper: proportional reduction | |
| # -------------------------- | |
| def proportional_reduction(baseline_votes, group_keys, transfers_amount): | |
| reduced = {} | |
| group_total = sum(baseline_votes[k] for k in group_keys) | |
| if group_total <= 0 or transfers_amount <= 0: | |
| for k in group_keys: | |
| reduced[k] = baseline_votes[k] | |
| return reduced | |
| for k in group_keys: | |
| share = baseline_votes[k] / group_total | |
| reduced[k] = baseline_votes[k] - transfers_amount * share | |
| return reduced | |
| # -------------------------- | |
| # Function to compute final votes for any pair of (pct_left, pct_right), | |
| # keeping non_voters_convinced fixed to the UI value. | |
| # -------------------------- | |
| def compute_final_votes(pct_left, pct_right, non_voters_fixed): | |
| left_pool_votes = baseline["Labour"] + baseline["Green"] | |
| right_pool_votes = baseline["Conservative"] + baseline["Reform"] | |
| left_transfers = left_pool_votes * (pct_left / 100.0) | |
| right_transfers = right_pool_votes * (pct_right / 100.0) | |
| reduced_left = proportional_reduction(baseline, ["Labour", "Green"], left_transfers) | |
| reduced_right = proportional_reduction(baseline, ["Conservative", "Reform"], right_transfers) | |
| final = { | |
| "Labour": reduced_left["Labour"], | |
| "Green": reduced_left["Green"], | |
| "Conservative": reduced_right["Conservative"], | |
| "Reform": reduced_right["Reform"], | |
| "LibDem": baseline["LibDem"], | |
| "Your Party": baseline["Your Party"] + left_transfers + right_transfers + non_voters_fixed, | |
| } | |
| return final | |
| # -------------------------- | |
| # Current single-scenario outcome (keeps behavior of the previous app) | |
| # -------------------------- | |
| final_votes = compute_final_votes(percent_left_switch, percent_right_switch, non_voters_convinced) | |
| df = pd.DataFrame({"party": list(final_votes.keys()), "votes": list(final_votes.values())}) | |
| df = df.sort_values("votes", ascending=False).reset_index(drop=True) | |
| top_party = df.loc[0, "party"] | |
| # Plot horizontal bars (current scenario) | |
| bar_colors = [PARTY_COLORS.get(p, "#999999") for p in df["party"]] | |
| marker_line_width = [4 if p == top_party else 1 for p in df["party"]] | |
| marker_line_color = ["black" if p == top_party else "rgba(0,0,0,0.3)" for p in df["party"]] | |
| fig_main = go.Figure() | |
| fig_main.add_trace( | |
| go.Bar( | |
| x=df["votes"], | |
| y=df["party"], | |
| orientation="h", | |
| marker=dict(color=bar_colors, line=dict(color=marker_line_color, width=marker_line_width)), | |
| hovertemplate="%{y}: %{x:,.0f} votes<extra></extra>", | |
| ) | |
| ) | |
| fig_main.update_layout(xaxis_title="Votes (absolute number)", yaxis_title="Party", | |
| margin=dict(l=140, r=30, t=60, b=40), height=420, showlegend=False) | |
| fig_main.add_annotation( | |
| x=df.loc[0, "votes"], y=df.loc[0, "party"], xanchor="left", yanchor="middle", | |
| text=f" Top: {df.loc[0,'party']} ({df.loc[0,'votes']:.0f} votes)", | |
| font=dict(size=12, color="black", family="Arial"), | |
| showarrow=False, bgcolor="rgba(255,255,255,0.8)", bordercolor="black", borderwidth=1, | |
| ) | |
| st.plotly_chart(fig_main, use_container_width=True) | |
| col1, col2, col3 = st.columns(3) | |
| new_total_votes = sum(final_votes.values()) | |
| new_turnout = new_total_votes / E * 100.0 | |
| col1.metric("Total votes (new)", f"{new_total_votes:,.0f}") | |
| col2.metric("New turnout", f"{new_turnout:.1f}%") | |
| col3.metric("Your Party votes", f"{final_votes['Your Party']:,.0f}") | |
| with st.expander("Show numeric results"): | |
| st.dataframe(df.style.format({"votes": "{:,.0f}"}), height=260) | |
| # -------------------------- | |
| # Static scenario charts requested: | |
| # - Two univariate plots: | |
| # 1) Vary percent_left (0..max_range) with percent_right fixed at current UI value. | |
| # 2) Vary percent_right (0..max_range) with percent_left fixed at current UI value. | |
| # For each univariate plot show Your Party votes and mark ranges where Your Party is the winner. | |
| # - One surface plot (heatmap) over grid of (pct_left, pct_right), showing winner. | |
| # | |
| # Also compute and state the ranges of (pct_left, pct_right) where: | |
| # - Your Party wins | |
| # - A right party wins (Conservative or Reform) | |
| # -------------------------- | |
| st.markdown("---") | |
| st.header("Scenario maps — how switching percentages change outcomes") | |
| # grid resolution and ranges | |
| max_range = 60 # percent range to explore for each axis (0..max_range) | |
| step = 1 | |
| left_values = np.arange(0.0, max_range + step, step) | |
| right_values = np.arange(0.0, max_range + step, step) | |
| # Precompute grid outcomes (winner and Your Party votes) | |
| grid_winner = np.empty((len(left_values), len(right_values)), dtype=np.int8) # 1=Your Party, 2=Right party, 0=Other | |
| grid_your_votes = np.empty_like(grid_winner, dtype=float) | |
| for i, lp in enumerate(left_values): | |
| for j, rp in enumerate(right_values): | |
| fv = compute_final_votes(lp, rp, non_voters_convinced) | |
| # determine winner | |
| winner = max(fv.items(), key=lambda kv: kv[1])[0] | |
| grid_your_votes[i, j] = fv["Your Party"] | |
| if winner == "Your Party": | |
| grid_winner[i, j] = 1 | |
| elif winner in ("Conservative", "Reform"): | |
| grid_winner[i, j] = 2 | |
| else: | |
| grid_winner[i, j] = 0 | |
| # Determine where Your Party wins and where a right party wins | |
| your_win_points = np.argwhere(grid_winner == 1) | |
| right_win_points = np.argwhere(grid_winner == 2) | |
| def describe_axis_ranges(points, axis_values): | |
| # returns min and max axis values where points exist, or None if empty | |
| if len(points) == 0: | |
| return None, None | |
| vals = axis_values[points] | |
| return float(vals.min()), float(vals.max()) | |
| # For a compact description, compute bounding box ranges (min/max left and right percents) for Your Party and right party | |
| if your_win_points.size > 0: | |
| your_left_min = left_values[your_win_points[:, 0]].min() | |
| your_left_max = left_values[your_win_points[:, 0]].max() | |
| your_right_min = right_values[your_win_points[:, 1]].min() | |
| your_right_max = right_values[your_win_points[:, 1]].max() | |
| else: | |
| your_left_min = your_left_max = your_right_min = your_right_max = None | |
| if right_win_points.size > 0: | |
| r_left_min = left_values[right_win_points[:, 0]].min() | |
| r_left_max = left_values[right_win_points[:, 0]].max() | |
| r_right_min = right_values[right_win_points[:, 1]].min() | |
| r_right_max = right_values[right_win_points[:, 1]].max() | |
| else: | |
| r_left_min = r_left_max = r_right_min = r_right_max = None | |
| # Percent of grid where each outcome occurs | |
| total_cells = grid_winner.size | |
| your_win_pct = (grid_winner == 1).sum() / total_cells * 100.0 | |
| right_win_pct = (grid_winner == 2).sum() / total_cells * 100.0 | |
| # Print concise range summaries | |
| st.subheader("Outcome ranges (within scanned 0–{}%)".format(max_range)) | |
| col_a, col_b = st.columns(2) | |
| with col_a: | |
| if your_win_points.size > 0: | |
| st.write("**Your Party wins** for roughly:") | |
| st.write(f"- Left switching: {your_left_min:.0f}% – {your_left_max:.0f}%") | |
| st.write(f"- Right switching: {your_right_min:.0f}% – {your_right_max:.0f}%") | |
| st.write(f"- Grid coverage: {your_win_pct:.1f}% of scanned scenarios") | |
| else: | |
| st.write("**Your Party does not win** in any scanned scenario (0–{}%).".format(max_range)) | |
| with col_b: | |
| if right_win_points.size > 0: | |
| st.write("**A right party (Conservative or Reform) wins** for roughly:") | |
| st.write(f"- Left switching: {r_left_min:.0f}% – {r_left_max:.0f}%") | |
| st.write(f"- Right switching: {r_right_min:.0f}% – {r_right_max:.0f}%") | |
| st.write(f"- Grid coverage: {right_win_pct:.1f}% of scanned scenarios") | |
| else: | |
| st.write("**Right parties do not win** in any scanned scenario (0–{}%).".format(max_range)) | |
| # -------------------------- | |
| # Univariate plot 1: vary left percent, keep right fixed at current UI value | |
| # -------------------------- | |
| your_votes_left = [] | |
| winners_left = [] | |
| for lp in left_values: | |
| fv = compute_final_votes(lp, percent_right_switch, non_voters_convinced) | |
| your_votes_left.append(fv["Your Party"]) | |
| winners_left.append(max(fv.items(), key=lambda kv: kv[1])[0]) | |
| fig_left = go.Figure() | |
| fig_left.add_trace(go.Scatter(x=left_values, y=your_votes_left, mode="lines", name="Your Party votes", | |
| hovertemplate="Left switch: %{x:.0f}%<br>Your Party votes: %{y:,.0f}<extra></extra>")) | |
| # shade region where Your Party is the winner | |
| is_your_winner = np.array([1 if w == "Your Party" else 0 for w in winners_left]) | |
| if is_your_winner.any(): | |
| # create filled area under the line where your party wins | |
| win_x = left_values * is_your_winner | |
| win_y = np.array(your_votes_left) * is_your_winner | |
| # convert zeros to nan so fill only covers winning points | |
| win_x = np.where(is_your_winner, win_x, np.nan) | |
| win_y = np.where(is_your_winner, win_y, np.nan) | |
| fig_left.add_trace(go.Scatter(x=win_x, y=win_y, fill="tozeroy", name="Your Party wins region", | |
| hoverinfo="skip", opacity=0.25, marker=dict(color=PARTY_COLORS["Your Party"]))) | |
| fig_left.update_layout(title=f"Vary percent left switching (right fixed at {percent_right_switch:.1f}%)", | |
| xaxis_title="Percent of left voters switching to Your Party", | |
| yaxis_title="Your Party votes (absolute)", | |
| height=350, margin=dict(l=80, r=20, t=50, b=40)) | |
| st.plotly_chart(fig_left, use_container_width=True) | |
| # -------------------------- | |
| # Univariate plot 2: vary right percent, keep left fixed at current UI value | |
| # -------------------------- | |
| your_votes_right = [] | |
| winners_right = [] | |
| for rp in right_values: | |
| fv = compute_final_votes(percent_left_switch, rp, non_voters_convinced) | |
| your_votes_right.append(fv["Your Party"]) | |
| winners_right.append(max(fv.items(), key=lambda kv: kv[1])[0]) | |
| fig_right = go.Figure() | |
| fig_right.add_trace(go.Scatter(x=right_values, y=your_votes_right, mode="lines", name="Your Party votes", | |
| hovertemplate="Right switch: %{x:.0f}%<br>Your Party votes: %{y:,.0f}<extra></extra>")) | |
| is_your_winner_r = np.array([1 if w == "Your Party" else 0 for w in winners_right]) | |
| if is_your_winner_r.any(): | |
| win_x = right_values * is_your_winner_r | |
| win_y = np.array(your_votes_right) * is_your_winner_r | |
| win_x = np.where(is_your_winner_r, win_x, np.nan) | |
| win_y = np.where(is_your_winner_r, win_y, np.nan) | |
| fig_right.add_trace(go.Scatter(x=win_x, y=win_y, fill="tozeroy", name="Your Party wins region", | |
| hoverinfo="skip", opacity=0.25, marker=dict(color=PARTY_COLORS["Your Party"]))) | |
| fig_right.update_layout(title=f"Vary percent right switching (left fixed at {percent_left_switch:.1f}%)", | |
| xaxis_title="Percent of right voters switching to Your Party", | |
| yaxis_title="Your Party votes (absolute)", | |
| height=350, margin=dict(l=80, r=20, t=50, b=40)) | |
| st.plotly_chart(fig_right, use_container_width=True) | |
| # -------------------------- | |
| # Surface / heatmap plot showing winner across the grid | |
| # Use integer mapping: 0=Other/Left/LibDem, 1=Your Party, 2=Right party | |
| # -------------------------- | |
| z = grid_winner # shape (len(left_values), len(right_values)) | |
| # Map winner codes to colors and labels | |
| color_for_code = { | |
| 0: PARTY_COLORS["Other"], | |
| 1: PARTY_COLORS["Your Party"], | |
| 2: PARTY_COLORS["RightWinner"], | |
| } | |
| # Build a simple three-stop colorscale: 0 -> code0 color, 0.5 -> code1 color, 1 -> code2 color | |
| # z ranges from 0..2, so we normalize positions across [0,1] | |
| colorscale = [ | |
| [0.0, color_for_code[0]], | |
| [0.5, color_for_code[1]], | |
| [1.0, color_for_code[2]], | |
| ] | |
| # Create heatmap: x axis = right percent, y axis = left percent | |
| fig_heat = go.Figure(data=go.Heatmap( | |
| z=z, | |
| x=right_values, | |
| y=left_values, | |
| colorscale=colorscale, | |
| zmin=0, | |
| zmax=2, | |
| colorbar=dict( | |
| title="Winner", | |
| tickmode="array", | |
| tickvals=[0, 1, 2], | |
| ticktext=["Other", "Your Party", "Right party"], | |
| ), | |
| hovertemplate="Left %{y:.0f}%<br>Right %{x:.0f}%<br>Winner: %{z}<extra></extra>", | |
| )) | |
| fig_heat.update_layout( | |
| title="Outcome surface (grid): winner by percent-left / percent-right", | |
| xaxis_title="Percent right switching to Your Party", | |
| yaxis_title="Percent left switching to Your Party", | |
| height=600, | |
| margin=dict(l=80, r=40, t=60, b=60), | |
| ) | |
| st.plotly_chart(fig_heat, use_container_width=True) | |
| # -------------------------- | |
| # Additional numeric summary: numerical ranges of interest as lists (not only bounding boxes) | |
| # Provide examples of threshold pairs where Your Party first becomes top (approx) | |
| # -------------------------- | |
| st.markdown("### Additional notes and diagnostics") | |
| # Find minimal sum of switches at which Your Party wins: example diagnostics | |
| your_indices = np.argwhere(z == 1) | |
| if your_indices.size > 0: | |
| # compute a "distance" metric (left+right) and find minimal combined switching where Your Party wins | |
| sums = left_values[your_indices[:, 0]] + right_values[your_indices[:, 1]] | |
| idx_min = np.argmin(sums) | |
| lp_min = left_values[your_indices[idx_min, 0]] | |
| rp_min = right_values[your_indices[idx_min, 1]] | |
| st.write(f"- Earliest (by sum of switches) scenario where Your Party wins in scanned grid: " | |
| f"Left = {lp_min:.0f}%, Right = {rp_min:.0f}% (sum = {lp_min+rp_min:.0f}%).") | |
| else: | |
| st.write("- Your Party never wins in the scanned 0–{}% grid.".format(max_range)) | |
| if right_win_points.size > 0: | |
| # similarly earliest right-party win by sum | |
| r_indices = np.argwhere(z == 2) | |
| sums_r = left_values[r_indices[:, 0]] + right_values[r_indices[:, 1]] | |
| idx_min_r = np.argmin(sums_r) | |
| lr_min = left_values[r_indices[idx_min_r, 0]] | |
| rr_min = right_values[r_indices[idx_min_r, 1]] | |
| st.write(f"- Earliest (by sum of switches) scenario where a right party wins: " | |
| f"Left = {lr_min:.0f}%, Right = {rr_min:.0f}% (sum = {lr_min+rr_min:.0f}%).") | |
| else: | |
| st.write("- Right parties never win in the scanned 0–{}% grid.".format(max_range)) | |
| st.caption("Notes: grids and ranges are computed for percent values between 0 and {} (step {}). " | |
| "Non-voters convinced is held fixed to the value selected in the sidebar for these scenario maps." | |
| .format(max_range, step)) | |