bdv / app /app.py
stephmnt's picture
Sync from GitHub Actions
46f9144 verified
import gradio as gr
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# =========================
# Chargement des données
# =========================
DATA_PATH = "data/processed/bv_features.parquet"
df = pd.read_parquet(DATA_PATH)
df["date_scrutin"] = pd.to_datetime(df.get("date_scrutin"), errors="coerce") # type: ignore
df["tour"] = pd.to_numeric(df.get("tour"), errors="coerce").astype("Int64") # type: ignore
# -------------------------
# Filtrage Sète uniquement
# -------------------------
# Hypothèse : code_commune INSEE
SETE_CODE_INSEE = "34301"
def resolve_code_commune(df_in: pd.DataFrame) -> tuple[pd.DataFrame, str | None]:
df_out = df_in.copy()
if "code_commune" in df_out.columns:
df_out["code_commune"] = df_out["code_commune"].astype("string")
return df_out, None
if "Code de la commune" in df_out.columns:
df_out = df_out.rename(columns={"Code de la commune": "code_commune"})
df_out["code_commune"] = df_out["code_commune"].astype("string")
return df_out, None
if "code_bv" in df_out.columns:
df_out["code_commune"] = df_out["code_bv"].astype(str).str.slice(0, 5)
df_out["code_commune"] = df_out["code_commune"].astype("string")
valid = df_out["code_commune"].str.len() == 5
if not valid.any():
return df_out, "Impossible de dériver code_commune depuis code_bv (format inattendu)."
return df_out, None
df_out["code_commune"] = pd.NA
return df_out, "Aucune colonne commune disponible (code_commune/Code de la commune/code_bv)."
df, commune_warning = resolve_code_commune(df)
df["code_commune"] = (
df["code_commune"]
.astype(str)
.str.replace(".0", "", regex=False)
.str.replace(r"\D", "", regex=True)
.str.zfill(5)
.astype("string")
)
df_sete = df[df["code_commune"] == SETE_CODE_INSEE].copy()
df_sete["tour"] = pd.to_numeric(df_sete["tour"], errors="coerce").astype("Int64")
# Colonnes blocs
BASE_BLOCS = [
"droite_modere",
"gauche_modere",
"gauche_dure",
"droite_dure",
"centre",
"extreme_gauche",
"extreme_droite",
"autre",
]
BLOC_LABELS = [b for b in BASE_BLOCS if f"part_bloc_{b}" in df_sete.columns]
BLOC_COLS = [f"part_bloc_{b}" for b in BLOC_LABELS]
# =========================
# Fonctions métier
# =========================
def compute_national_reference(df_all, type_scrutin, tour):
"""
Calcule les parts nationales par bloc pour un scrutin et un tour donnés.
"""
if not BLOC_COLS:
return {}
df_nat = df_all[
(df_all["type_scrutin"] == type_scrutin)
& (df_all["tour"] == tour)
]
# pondération par exprimés
weights = df_nat["exprimes"].replace(0, np.nan)
national = {}
for col in BLOC_COLS:
national[col] = np.nansum(df_nat[col] * weights) / np.nansum(weights)
return national
def table_sete(type_scrutin, tour):
if not BLOC_COLS:
return pd.DataFrame({"info": ["Colonnes part_bloc_* absentes."]})
tour_val = pd.to_numeric(tour, errors="coerce")
if pd.isna(tour_val):
return pd.DataFrame({"info": ["Tour invalide."]})
# données locales
local = df_sete[
(df_sete["type_scrutin"] == type_scrutin)
& (df_sete["tour"] == int(tour_val))
].copy()
if local.empty:
return pd.DataFrame({"info": ["Aucune donnée disponible"]})
# référence nationale
nat = compute_national_reference(df, type_scrutin, tour)
# construction tableau affiché
rows = []
for _, row in local.iterrows():
r = {
"code_bv": row["code_bv"],
"nom_bv": row.get("nom_bv", ""),
}
for col in BLOC_COLS:
part = row[col]
ecart = part - nat.get(col, 0)
r[col.replace("part_bloc_", "")] = round(part * 100, 2)
r[col.replace("part_bloc_", "") + "_ecart_nat"] = round(ecart * 100, 2)
rows.append(r)
result = pd.DataFrame(rows)
# tri par écart extrême droite (exemple)
if "extreme_droite_ecart_nat" in result.columns:
result = result.sort_values(
"extreme_droite_ecart_nat", ascending=False
)
return result
def get_bv_timeseries(code_bv: str, tour: int | None) -> pd.DataFrame:
if df_sete.empty or not BLOC_COLS:
return pd.DataFrame(columns=["date_scrutin"] + BLOC_COLS)
subset = df_sete[df_sete["code_bv"].astype(str) == str(code_bv)].copy()
subset["tour"] = pd.to_numeric(subset["tour"], errors="coerce").astype("Int64")
if tour is not None:
subset = subset[subset["tour"] == tour]
subset = subset.dropna(subset=["date_scrutin"]).sort_values("date_scrutin")
return subset[["date_scrutin"] + BLOC_COLS]
def plot_bv_timeseries(code_bv: str, tour_choice, bloc_choices=None):
tour = None if tour_choice == "Tous" else int(tour_choice)
fig, ax = plt.subplots(figsize=(8, 4))
if not BLOC_COLS:
ax.text(0.5, 0.5, "Colonnes part_bloc_* absentes.", ha="center", va="center")
ax.axis("off")
return fig
df_ts = get_bv_timeseries(code_bv, tour)
if df_ts.empty:
tours_avail = (
df_sete[df_sete["code_bv"].astype(str) == str(code_bv)]["tour"]
.dropna()
.unique()
.tolist()
)
ax.text(
0.5,
0.5,
f"Aucune donnée après filtre tour={tour}. Valeurs disponibles: {sorted(tours_avail)}",
ha="center",
va="center",
wrap=True,
)
ax.axis("off")
return fig
selected = bloc_choices or BLOC_LABELS
selected_cols = [f"part_bloc_{b}" for b in selected if f"part_bloc_{b}" in df_ts.columns]
if not selected_cols:
ax.text(0.5, 0.5, "Aucun bloc sélectionné.", ha="center", va="center")
ax.axis("off")
return fig
for col in selected_cols:
ax.plot(df_ts["date_scrutin"], df_ts[col], label=col.replace("part_bloc_", ""))
ax.set_title(f"Évolution politique – BV {code_bv}")
ax.set_ylabel("Part des voix (exprimés)")
ax.grid(True, alpha=0.3)
ax.legend(bbox_to_anchor=(1.02, 1), loc="upper left", borderaxespad=0, fontsize=8)
fig.autofmt_xdate()
fig.tight_layout()
return fig
# =========================
# Interface Gradio
# =========================
def format_bv_label(code_bv: str) -> str:
code_str = str(code_bv)
if code_str.isdigit() and code_str.startswith(SETE_CODE_INSEE) and len(code_str) == 9:
bureau_num = code_str[-4:]
return f"BV {int(bureau_num)} ({code_str})"
return code_str
bv_values = (
sorted(df_sete["code_bv"].astype(str).unique().tolist())
if "code_bv" in df_sete.columns
else []
)
bv_choices = [(format_bv_label(code), code) for code in bv_values]
scrutins = sorted(df_sete["type_scrutin"].unique())
tours = sorted(df_sete["tour"].dropna().unique())
tour_options = ["Tous"] + [str(t) for t in tours]
status_messages = []
if commune_warning:
status_messages.append(commune_warning)
if df_sete.empty:
status_messages.append(
"Aucune ligne pour la commune 34301 (Sète). Vérifie `code_commune` / le filtre."
)
if not BLOC_COLS:
status_messages.append("Colonnes part_bloc_* absentes dans bv_features.")
missing_blocs = [f"part_bloc_{b}" for b in BASE_BLOCS if f"part_bloc_{b}" not in df_sete.columns]
if missing_blocs:
status_messages.append(f"Colonnes blocs manquantes: {', '.join(missing_blocs)}")
tour_dtype = str(df_sete["tour"].dtype) if "tour" in df_sete.columns else "n/a"
tour_sample = sorted(df_sete["tour"].dropna().unique().tolist())[:10]
status_messages.append(f"tour dtype: {tour_dtype}")
status_messages.append(f"tours disponibles (échantillon): {tour_sample}")
status_messages.append(
f"df_sete: {len(df_sete)} lignes, {df_sete['code_bv'].nunique() if 'code_bv' in df_sete.columns else 0} BV"
)
status_messages.append(f"blocs actifs: {', '.join(BLOC_LABELS) if BLOC_LABELS else 'aucun'}")
status_text = "\n".join(f"- {msg}" for msg in status_messages)
with gr.Blocks(title="Résultats électoraux – Bureaux de vote de Sète") as app:
gr.Markdown(
"""
# 🗳️ Résultats électoraux – Ville de Sète
**Bureaux de vote uniquement – comparaison au niveau national**
Les pourcentages sont exprimés en **% des exprimés**.
Les écarts sont en **points par rapport au national**.
"""
)
if status_text:
gr.Markdown(f"**Alertes**\n{status_text}")
with gr.Tabs():
with gr.Tab("Bureaux de vote"):
with gr.Row():
type_scrutin = gr.Dropdown(
scrutins,
label="Type de scrutin",
value=scrutins[0] if scrutins else None,
)
tour = gr.Dropdown(
tours,
label="Tour",
value=tours[0] if tours else None,
)
output = gr.Dataframe(
label="Bureaux de vote – parts locales et écart au national",
interactive=False,
wrap=True,
)
btn = gr.Button("Afficher")
btn.click(
fn=table_sete,
inputs=[type_scrutin, tour],
outputs=output,
)
with gr.Tab("Évolution temporelle"):
bv_selector = gr.Dropdown(
bv_choices,
label="Bureau de vote",
value=bv_values[0] if bv_values else None,
)
tour_selector = gr.Dropdown(
tour_options,
label="Tour",
value="Tous",
)
blocs_selector = gr.Dropdown(
BLOC_LABELS,
label="Blocs à afficher",
value=BLOC_LABELS,
multiselect=True,
)
plot = gr.Plot(
value=plot_bv_timeseries(
bv_values[0] if bv_values else "", "Tous", BLOC_LABELS
)
)
bv_selector.change(
fn=plot_bv_timeseries,
inputs=[bv_selector, tour_selector, blocs_selector],
outputs=plot,
)
tour_selector.change(
fn=plot_bv_timeseries,
inputs=[bv_selector, tour_selector, blocs_selector],
outputs=plot,
)
blocs_selector.change(
fn=plot_bv_timeseries,
inputs=[bv_selector, tour_selector, blocs_selector],
outputs=plot,
)
# =========================
# Lancement
# =========================
# Tests manuels:
# 1) Lancer l'app.
# 2) Onglet "Évolution temporelle": choisir un BV, tester Tous / Tour 1 / Tour 2.
# 3) Vérifier que la légende n'occulte pas les courbes et que seuls 8 blocs apparaissent.
# 4) Vérifier le libellé BV (BV X + code) et les alertes en haut de page.
if __name__ == "__main__":
app.launch()