spb/qc-election
Public
Python 66.6%
HTML 24.8%
CSS 4.9%
JavaScript 3.6%
1# QC Élection Forecast — Plateforme de prévision électorale du Québec 20262# Auteur : Simon-Pierre Boucher3# Contact : contact@spboucher.ai4# https://www.qc-election.com5"""Élections partielles 2022-2026 — les seuls VOTES RÉELS disponibles avant le6scrutin général (v2 « au-delà des sondages »).78Chaque partielle est convertie en observation nationale bruitée :910 alr_national_implicite = alr(national 2022)11 + rétrécissement × [ alr(résultat partielle) − alr(résultat 2022 local) ]1213Le « swing » local observé est rétréci (défaut 0,6) car la littérature montre14que les partielles EXAGÈRENT le mouvement national (participation faible, vote15protestataire, absence d'enjeu de gouvernement). L'observation reçoit une16grande variance (n équivalent ~350, erreur excédentaire ±3 pp) : elle informe17le filtre de Kalman sans jamais le dominer. Contrairement à un sondage, c'est18un bulletin de vote compté — la seule vérité de terrain d'ici le 5 octobre.1920Les résultats embarqués proviennent du DGEQ (recopiés; `verified=False` tant21que la vérification Firecrawl n'a pas confirmé les pourcentages en ligne).22"""23from __future__ import annotations2425import logging26import re27import unicodedata28from datetime import date2930import numpy as np31import pandas as pd32from sqlalchemy.orm import Session3334from ..config import DATA_DIR, settings35from .. import models as Mo36from ..modeling.compositions import alr, close, inv_alr3738log = logging.getLogger("byelections")3940RESULT_2022 = {"CAQ": 40.98, "PLQ": 14.37, "QS": 15.43, "PQ": 14.61, "PCQ": 12.91, "AUT": 1.70}4142# Résultats des partielles (DGEQ). AUT = candidats restants.43BYELECTIONS = [44 {"district": "Saint-Henri-Sainte-Anne", "held_on": date(2023, 3, 13),45 "winner": "QS", "previous_winner": "PLQ", "turnout": 35.7,46 "result": {"QS": 44.2, "PLQ": 21.4, "PQ": 15.7, "CAQ": 8.8, "PCQ": 3.4, "AUT": 6.5},47 "source_url": "https://fr.wikipedia.org/wiki/Saint-Henri%E2%80%93Sainte-Anne",48 "notes": "Démission de Dominique Anglade — gain QS (Guillaume Cliche-Rivard)."},49 {"district": "Jean-Talon", "held_on": date(2023, 10, 2),50 "winner": "PQ", "previous_winner": "CAQ", "turnout": 52.4,51 "result": {"PQ": 44.0, "CAQ": 20.3, "QS": 13.0, "PCQ": 9.1, "PLQ": 8.9, "AUT": 4.7},52 "source_url": "https://fr.wikipedia.org/wiki/Jean-Talon_(circonscription)",53 "notes": "Démission de Joëlle Boutin — gain PQ (Pascal Paradis), premier signal du réalignement."},54 {"district": "Terrebonne", "held_on": date(2025, 3, 17),55 "winner": "PQ", "previous_winner": "CAQ", "turnout": 37.9,56 "result": {"PQ": 46.5, "CAQ": 18.5, "PLQ": 15.5, "PCQ": 9.0, "QS": 5.5, "AUT": 5.0},57 "source_url": "https://fr.wikipedia.org/wiki/Terrebonne_(circonscription)",58 "notes": "Gain PQ (Catherine Gentilcore) dans un château fort caquiste de 2022."},59 {"district": "Arthabaska", "held_on": date(2025, 8, 11),60 "winner": "PQ", "previous_winner": "CAQ", "turnout": 56.2,61 "result": {"PQ": 46.5, "PCQ": 35.0, "CAQ": 7.0, "PLQ": 5.0, "QS": 1.5, "AUT": 5.0},62 "source_url": "https://fr.wikipedia.org/wiki/Arthabaska_(circonscription)",63 "notes": "Gain PQ (Alex Boissonneault) devant Éric Duhaime (PCQ); effondrement CAQ."},64]656667def _norm(s: str) -> str:68 s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()69 return re.sub(r"[^a-z]", "", s.lower())707172def _riding_2022(district: str) -> dict[str, float] | None:73 """Résultat 2022 de la circonscription (carte 2022) — depuis le CSV canonique."""74 f = DATA_DIR / "riding_results_2022.csv"75 if not f.exists():76 return None77 df = pd.read_csv(f)78 key = _norm(district)79 for _, r in df.iterrows():80 if _norm(str(r["district"])) == key or key in _norm(str(r["district"])):81 return {p: float(r[p]) for p in settings.parties if p in r}82 return None838485def ensure_byelections(db: Session) -> int:86 """Upsert des partielles embarquées (idempotent, n'écrase pas une ligne vérifiée)."""87 added = 088 for b in BYELECTIONS:89 row = (db.query(Mo.ByElection)90 .filter_by(district_name=b["district"], held_on=b["held_on"]).first())91 if row is None:92 row = Mo.ByElection(district_name=b["district"], held_on=b["held_on"],93 result=b["result"], turnout=b["turnout"],94 winner=b["winner"], previous_winner=b["previous_winner"],95 source_url=b["source_url"], notes=b["notes"])96 db.add(row)97 added += 198 elif not row.verified:99 row.result = b["result"]; row.turnout = b["turnout"]100 row.source_url = b["source_url"]; row.notes = b["notes"]101 db.commit()102 return added103104105def verify_with_firecrawl(db: Session) -> dict:106 """Vérification best-effort des pourcentages en ligne (Wikipédia via Firecrawl).107 Ne met à jour une partielle que si ≥5 partis sont extraits et que la somme108 est plausible; sinon les valeurs embarquées restent."""109 from .firecrawl_watch import scrape_markdown110 report = {}111 aliases = {"CAQ": ["coalition avenir"], "PLQ": ["libéral"], "PQ": ["parti québécois"],112 "QS": ["québec solidaire"], "PCQ": ["conservateur"]}113 for row in db.query(Mo.ByElection).filter_by(verified=False).all():114 if not row.source_url:115 continue116 md = scrape_markdown(row.source_url)117 if not md:118 report[row.district_name] = "scrape échoué"119 continue120 # fenêtre de texte autour de la date de la partielle121 year = str(row.held_on.year)122 idx = md.lower().find(f"partielle")123 window = md[max(0, idx - 500):idx + 6000] if idx >= 0 else md124 if year not in window:125 report[row.district_name] = "section partielle introuvable"126 continue127 found = {}128 low = window.lower()129 for party, keys in aliases.items():130 for k in keys:131 for m in re.finditer(re.escape(k), low):132 seg = window[m.end():m.end() + 120]133 pm = re.search(r"(\d{1,2}[.,]\d{1,2})\s*%", seg)134 if pm:135 v = float(pm.group(1).replace(",", "."))136 if 0.1 <= v <= 75:137 found[party] = v138 break139 if party in found:140 break141 if len(found) >= 5 and 80 <= sum(found.values()) <= 105:142 found["AUT"] = max(0.5, round(100 - sum(found.values()), 2))143 row.result = found144 row.verified = True145 report[row.district_name] = f"vérifié: {found}"146 else:147 report[row.district_name] = f"extraction incomplète ({len(found)} partis)"148 db.commit()149 return report150151152def pseudo_polls(db: Session, as_of: date | None = None) -> list[dict]:153 """Partielles → observations nationales implicites, au format « sondage »154 consommé par le filtre de Kalman (avec erreur excédentaire dédiée)."""155 if not settings.byelections_enabled:156 return []157 out = []158 nat = close(np.array([RESULT_2022[p] for p in settings.parties]) / 100.0)159 for row in db.query(Mo.ByElection).filter_by(used_in_model=True).all():160 if as_of and row.held_on > as_of:161 continue162 base = _riding_2022(row.district_name)163 if base is None:164 log.warning("partielle %s : baseline 2022 introuvable", row.district_name)165 continue166 b = close(np.array([base.get(p, 0.5) for p in settings.parties]) / 100.0)167 r = close(np.array([row.result.get(p, 0.5) for p in settings.parties]) / 100.0)168 implied = inv_alr(alr(nat) + settings.byelection_swing_shrink * (alr(r) - alr(b)))169 out.append({170 "id": None,171 "pollster": f"Vote réel — {row.district_name}",172 "field_end": row.held_on,173 "sample_size": settings.byelection_equivalent_n,174 "mode": "byelection",175 "excess_pp": settings.byelection_excess_pp,176 "is_byelection": True,177 "shares": {p: round(float(implied[i] * 100), 2)178 for i, p in enumerate(settings.parties)},179 })180 return out181