SPB Git forge

spb/qc-election

Public
20commits 1branches 0releases
4.9 MBsize
maindefault branch
20 days agolast push
Python 66.6% HTML 24.8% CSS 4.9% JavaScript 3.6%
8.1 KB · 181 lines python
Raw Blame History
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