SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
8.3 KB · 209 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# air.py : qualité de l'air à l'adresse — RSQAQ (MELCCFP, Données Québec)5#6#   Sources ouvertes officielles du Réseau de surveillance de la qualité de7#   l'air du Québec :8#     · stations (rsqaq-stations) : coordonnées, ouverture/fermeture ;9#     · données horaires continues (PM2.5, NO2, O3, SO2, CO) 2024-2025 ;10#     · données séquentielles (Concentration PST / PM10 + métaux) 2007-2026 —11#       les particules en suspension totales demandées (étiquette PST).12#   `refresh()` télécharge les CSV et réduit tout en moyennes annuelles par13#   station dans data/air.db (minuscule) ; `lookup(lat, lng)` remonte la14#   station la plus proche (≤ 60 km) avec ses mesures les plus récentes,15#   comparées aux repères annuels de l'OMS (2021).16# -----------------------------------------------------------------------------17from __future__ import annotations1819import csv20import io21import math22import sqlite323import time24import urllib.request25from pathlib import Path2627DB_PATH = Path(__file__).resolve().parent.parent / "data" / "air.db"28UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)"2930URL_STATIONS = ("https://www.donneesquebec.ca/recherche/dataset/"31                "8656ad05-c174-41c5-9ed7-8c69d308beb9/resource/"32                "cebea532-a9e0-4a39-8c2d-54f33d937c73/download/"33                "rsqaq_stations_de_la_qualite_de_lair.csv")34URL_HORAIRE = {35    2025: ("https://www.donneesquebec.ca/recherche/dataset/"36           "a80757bd-d442-4d3d-9269-11628330b727/resource/"37           "370a6be4-1530-4c2b-92f3-0a308224f284/download/"38           "rsqaq_continues_horaires_2025.csv"),39    2024: ("https://www.donneesquebec.ca/recherche/dataset/"40           "a80757bd-d442-4d3d-9269-11628330b727/resource/"41           "135146b7-fd4a-4564-b10b-b1ae94257889/download/"42           "rsqaq_continues_horaires_2024.csv"),43}44URL_SEQ = ("https://www.donneesquebec.ca/recherche/dataset/"45           "bff56fad-22c3-450b-aafa-4ccdad6c91f2/resource/"46           "b83cac3b-6199-4cfd-a903-cdec2369630c/download/"47           "rsqaq_sequentielles_2007-2026.csv")4849# colonnes horaires retenues -> polluant canonique50_HOURLY = {"PM2.5-T640": "PM2.5", "PM2.5-BAM": "PM2.5", "NO2": "NO2",51           "O3": "O3", "SO2": "SO2", "CO": "CO"}52# contaminants séquentiels retenus (concentrations de particules)53_SEQ = {"Concentration PST": "PST", "Concentration PM10": "PM10"}5455# repères ANNUELS (moyenne) : OMS 2021 pour PM2.5/PM10/NO2 ; PST : norme56# annuelle du Règlement sur l'assainissement de l'atmosphère (Québec)57REFS = {"PM2.5": ("OMS 2021", 5.0), "PM10": ("OMS 2021", 15.0),58        "NO2": ("OMS 2021", 10.0), "PST": ("RAA Québec", 60.0)}59UNITES = {"PM2.5": "µg/m³", "PM10": "µg/m³", "PST": "µg/m³",60          "NO2": "ppb", "O3": "ppb", "SO2": "ppb", "CO": "ppm"}616263def _dl(url: str) -> io.StringIO:64    req = urllib.request.Request(url, headers={"User-Agent": UA})65    with urllib.request.urlopen(req, timeout=300) as r:66        return io.StringIO(r.read().decode("utf-8-sig"))676869def refresh() -> None:70    stations: dict[str, dict] = {}71    for row in csv.DictReader(_dl(URL_STATIONS)):72        sid = row["ID_STATION"].strip().lstrip("0")73        stations[sid] = {"nom": row["NOM_STATION"], "ville": row["MUNICIPALITE"],74                         "lat": float(row["LATITUDE"] or 0),75                         "lng": float(row["LONGITUDE"] or 0)}76    print(f"[air] {len(stations)} stations")7778    # (station, annee, polluant) -> [somme, n]79    acc: dict[tuple, list[float]] = {}8081    def _sid(label: str) -> str:82        return label.split(" - ")[0].strip().lstrip("0")8384    for annee, url in URL_HORAIRE.items():85        n = 086        rd = csv.DictReader(_dl(url))87        cols = [c for c in (rd.fieldnames or []) if c in _HOURLY]88        for row in rd:89            sid = _sid(row["Station"])90            for c in cols:91                v = row.get(c)92                if not v:93                    continue94                try:95                    x = float(v)96                except ValueError:97                    continue98                if x < 0:99                    continue100                a = acc.setdefault((sid, annee, _HOURLY[c]), [0.0, 0])101                a[0] += x102                a[1] += 1103                n += 1104        print(f"[air] horaires {annee} : {n} mesures")105106    n = 0107    for row in csv.DictReader(_dl(URL_SEQ)):108        pol = _SEQ.get(row["Contaminant"])109        if not pol:110            continue111        annee = int(row["Date"][:4])112        if annee < 2022:113            continue114        v = row.get("Resultat")115        if not v:116            ld = row.get("LD")117            if not ld:118                continue119            try:120                x = float(ld) / 2.0            # convention < LD -> LD/2121            except ValueError:122                continue123        else:124            try:125                x = float(v)126            except ValueError:127                continue128        a = acc.setdefault((_sid(row["Station"]), annee, pol), [0.0, 0])129        a[0] += x130        a[1] += 1131        n += 1132    print(f"[air] séquentielles PST/PM10 : {n} mesures (≥ 2022)")133134    con = sqlite3.connect(DB_PATH)135    con.executescript("""136        DROP TABLE IF EXISTS air_stats;137        CREATE TABLE air_stats (station TEXT, nom TEXT, ville TEXT,138            lat REAL, lng REAL, annee INTEGER, polluant TEXT,139            moyenne REAL, n INTEGER);140        CREATE INDEX idx_air_latlng ON air_stats (lat, lng);141    """)142    now = time.strftime("%Y-%m-%d")143    rows = []144    for (sid, annee, pol), (somme, cnt) in acc.items():145        st = stations.get(sid)146        # minimum de couverture : 30 jours d'heures ou 15 échantillons147        if not st or cnt < (720 if pol in ("PM2.5", "NO2", "O3", "SO2", "CO")148                            else 15):149            continue150        rows.append((sid, st["nom"], st["ville"], st["lat"], st["lng"],151                     annee, pol, round(somme / cnt, 2), cnt))152    with con:153        con.executemany("INSERT INTO air_stats VALUES (?,?,?,?,?,?,?,?,?)",154                        rows)155        con.execute("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v)")156        con.execute("INSERT OR REPLACE INTO meta VALUES ('maj', ?)", (now,))157    print(f"[air] {len(rows)} agrégats station-année-polluant -> {DB_PATH}")158    con.close()159160161def _dist_km(lat1, lng1, lat2, lng2) -> float:162    dlat = math.radians(lat2 - lat1)163    dlng = math.radians(lng2 - lng1)164    a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1))165         * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2)166    return 6371 * 2 * math.asin(math.sqrt(a))167168169def lookup(lat: float, lng: float, max_km: float = 60.0) -> dict | None:170    """Mesures de la station RSQAQ la plus proche (année la plus récente)."""171    if not DB_PATH.exists():172        return None173    con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)174    con.row_factory = sqlite3.Row175    d = max_km / 111.0176    rows = con.execute(177        "SELECT * FROM air_stats WHERE lat BETWEEN ? AND ? AND lng BETWEEN "178        "? AND ?", (lat - d, lat + d, lng - d / max(0.2, math.cos(179            math.radians(lat))), lng + d / max(0.2, math.cos(180            math.radians(lat))))).fetchall()181    con.close()182    if not rows:183        return {"station": None}184    # station la plus proche disposant de données récentes185    best: dict[str, dict] = {}186    for r in rows:187        km = _dist_km(lat, lng, r["lat"], r["lng"])188        if km > max_km:189            continue190        b = best.setdefault(r["station"], {"km": km, "rows": [], "r": r})191        b["rows"].append(r)192    if not best:193        return {"station": None}194    sid, b = min(best.items(), key=lambda kv: kv[1]["km"])195    # par polluant : l'année la plus récente196    mesures: dict[str, dict] = {}197    for r in sorted(b["rows"], key=lambda r: -r["annee"]):198        if r["polluant"] in mesures:199            continue200        ref = REFS.get(r["polluant"])201        mesures[r["polluant"]] = {202            "moyenne": r["moyenne"], "annee": r["annee"], "n": r["n"],203            "unite": UNITES.get(r["polluant"], ""),204            "ref": ref[1] if ref else None,205            "ref_nom": ref[0] if ref else None}206    st = b["r"]207    return {"station": st["nom"], "ville": st["ville"],208            "distance_km": round(b["km"], 1), "mesures": mesures}209