SPB Git

spb/lou-ka Public

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

HTML 99.7%
19.7 KB · 433 lines python
Raw Blame History
1#!/usr/bin/env python32# -*- coding: utf-8 -*-3"""4Construction de data/staging-environnement.db — enrichissement « environnement »5des fiches Lou-Ka : îlots de chaleur (INSPQ), criminalité (SPVM), indice de6gravité de la criminalité (Statistique Canada).78Tables produites (schéma contractuel — ne pas modifier) :910  heat(coord_key TEXT PRIMARY KEY, classe INTEGER, ecart REAL)11      coord_key = "lat,lng" avec exactement 4 décimales, équivalent SQLite :12      printf('%.4f', ROUND(lat,4)) || ',' || printf('%.4f', ROUND(lng,4))13      classe : 1 à 9 (INSPQ ICFU 2020-2022). SENS VÉRIFIÉ dans les métadonnées14      du jeu Données Québec : classes 1-2-3 = îlots de FRAÎCHEUR urbains15      (1 = le plus frais), classes 8-9 = îlots de CHALEUR urbains (9 = le plus16      chaud). NULL si hors des centres de population couverts (nodata).17      ecart : écart de température relatif en °C par rapport à un boisé voisin18      (raster écoumène 2021). NULL si hors écoumène (nodata).1920  crime_mtl(id INTEGER PRIMARY KEY, lat REAL, lng REAL, ts REAL, categorie TEXT)21      Actes criminels SPVM des 24 derniers mois, coordonnées valides seulement.22      ts = epoch UTC (minuit) de la date de l'acte.2324  igc(service TEXT, annee INTEGER, indice REAL, taux REAL)25      indice = Indice de gravité de la criminalité (StatCan 35-10-0187 par26      corps de police ; 35-10-0026 pour le Canada).27      taux = taux de criminalité par 100 000 hab. (infractions au Code criminel28      sauf délits de la route, StatCan 35-10-0179 / 35-10-0177 via l'API WDS).29      5 dernières années disponibles.3031  meta(cle TEXT PRIMARY KEY, valeur TEXT) : sources, dates, attributions.3233Usage :34  .venv/bin/python scripts/build_environnement.py                 # tout reconstruire35  .venv/bin/python scripts/build_environnement.py --etapes crime  # rafraîchir la36      criminalité seulement (à relancer mensuellement)37  .venv/bin/python scripts/build_environnement.py --etapes chaleur \38      --coords nouvelles.txt   # échantillonner UNIQUEMENT une liste de nouvelles39      coordonnées (une paire "lat,lng" par ligne) et les upserter dans heat40  .venv/bin/python scripts/build_environnement.py --garder-cache  # ne pas41      supprimer les GeoTIFF téléchargés (~9,6 Go) après l'exécution4243Dépendances : rasterio, pyproj (pip install rasterio pyproj).44Licences : INSPQ/CERFO CC-BY 4.0 ; Ville de Montréal CC-BY 4.0 ;45Statistique Canada — licence ouverte.46"""4748import argparse49import calendar50import csv51import io52import json53import shutil54import sqlite355import sys56import urllib.request57import zipfile58from datetime import date, datetime, timedelta59from pathlib import Path6061RACINE = Path(__file__).resolve().parent.parent62DB_LOUKA = RACINE / "data" / "louka.db"63DB_STAGING = RACINE / "data" / "staging-environnement.db"64CACHE_DEFAUT = Path("/tmp/louka-env")6566# --- Sources -----------------------------------------------------------------67URL_TIF_CLASSES = ("https://dq-prd-bucket1.s3.ca-central-1.amazonaws.com/inspq/"68                   "ICFU2022_CentresPopulation2021_buf2000m_9cl.tif")69URL_TIF_ECART = ("https://dq-prd-bucket1.s3.ca-central-1.amazonaws.com/inspq/"70                 "EcartTemperatureRelatif2022_Ecoumene2021.tif")71URL_CRIME_CSV = ("https://donnees.montreal.ca/dataset/"72                 "5829b5b0-ea6f-476f-be94-bc2b8797769a/resource/"73                 "c6f482bf-bf0f-4960-8b2f-9982c211addd/download/actes-criminels.csv")74URL_STATCAN_ZIP = "https://www150.statcan.gc.ca/n1/tbl/csv/{pid}-fra.zip"75URL_WDS = ("https://www150.statcan.gc.ca/t1/wds/rest/"76           "getDataFromCubePidCoordAndLatestNPeriods")7778# Le portail donnees.montreal.ca refuse les clients sans User-Agent navigateur.79UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "80      "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")8182MOIS_CRIME = 24        # fenêtre criminalité (24 derniers mois)83ANNEES_IGC = 5         # nombre d'années IGC conservées8485# Corps de police retenus.86#  - code_geo : identifiant entre crochets dans la colonne GÉO des CSV StatCan87#    35-10-0187 (indice) et 35-10-0026 (Canada).88#  - pid_taux / geo_taux : cube et memberId Géographie pour le taux de89#    criminalité via l'API WDS (35-10-0179 = services de police au Québec,90#    35-10-0177 = Canada/provinces/RMR). Coordonnée = geo.infraction.stat.0*791#    avec infraction 3 = « Total des infractions au Code criminel (sauf les92#    délits de la route) » et statistique 2 = « Taux pour 100 000 habitants ».93SERVICES = [94    # (nom_affiché, code_geo, pid_indice, pid_taux, geo_taux)95    ("SPVQ (Ville de Québec)",     "24215", 35100187, 35100179, 107),96    ("Ville de Lévis",             "24219", 35100187, 35100179, 109),97    ("SPVM (Montréal)",            "24175", 35100187, 35100179, 82),98    ("Laval",                      "24141", 35100187, 35100179, 59),99    ("Longueuil (agglomération)",  "24231", 35100187, 35100179, 115),100    ("Québec (province)",          "24",    35100187, 35100179, 1),101    ("Canada",                     "CAN",   35100026, 35100177, 1),102]103104105# --- Utilitaires ---------------------------------------------------------------106def telecharger(url: str, dest: Path, description: str) -> None:107    """Télécharge url vers dest (flux, pas de chargement en mémoire)."""108    if dest.exists() and dest.stat().st_size > 0:109        print(f"  [cache] {description} déjà présent : {dest}")110        return111    print(f"  téléchargement {description} …")112    req = urllib.request.Request(url, headers={"User-Agent": UA})113    tmp = dest.with_suffix(dest.suffix + ".part")114    with urllib.request.urlopen(req, timeout=120) as rep, open(tmp, "wb") as f:115        shutil.copyfileobj(rep, f, length=1 << 20)116    tmp.rename(dest)117    print(f"  ok ({dest.stat().st_size / 1e6:.1f} Mo)")118119120def coord_key(lat: float, lng: float) -> str:121    """Clé "lat,lng" à 4 décimales — identique à122    printf('%.4f',ROUND(lat,4))||','||printf('%.4f',ROUND(lng,4)) en SQLite."""123    return f"{lat:.4f},{lng:.4f}"124125126def ouvrir_staging() -> sqlite3.Connection:127    DB_STAGING.parent.mkdir(parents=True, exist_ok=True)128    conn = sqlite3.connect(DB_STAGING)129    conn.executescript("""130        CREATE TABLE IF NOT EXISTS heat(131            coord_key TEXT PRIMARY KEY, classe INTEGER, ecart REAL);132        CREATE TABLE IF NOT EXISTS crime_mtl(133            id INTEGER PRIMARY KEY, lat REAL, lng REAL, ts REAL, categorie TEXT);134        CREATE INDEX IF NOT EXISTS idx_crime_lat ON crime_mtl(lat);135        CREATE TABLE IF NOT EXISTS igc(136            service TEXT, annee INTEGER, indice REAL, taux REAL);137        CREATE TABLE IF NOT EXISTS meta(cle TEXT PRIMARY KEY, valeur TEXT);138    """)139    return conn140141142def poser_meta(conn: sqlite3.Connection, cle: str, valeur: str) -> None:143    conn.execute("INSERT OR REPLACE INTO meta(cle, valeur) VALUES(?,?)",144                 (cle, valeur))145146147# --- Étape 1 : îlots de chaleur -------------------------------------------------148def coords_depuis_louka() -> list[tuple[float, float]]:149    """Coordonnées uniques (arrondies à 4 décimales par SQLite) des immeubles actifs."""150    conn = sqlite3.connect(f"file:{DB_LOUKA}?mode=ro", uri=True)151    lignes = conn.execute(152        "SELECT DISTINCT ROUND(lat,4), ROUND(lng,4) FROM listings "153        "WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL").fetchall()154    conn.close()155    return [(la, lo) for la, lo in lignes]156157158def coords_depuis_fichier(chemin: Path) -> list[tuple[float, float]]:159    """Fichier texte : une paire « lat,lng » par ligne (les valeurs sont160    ré-arrondies à 4 décimales)."""161    paires = []162    for ligne in chemin.read_text().splitlines():163        ligne = ligne.strip()164        if not ligne or ligne.startswith("#"):165            continue166        la, lo = ligne.split(",")167        paires.append((round(float(la), 4), round(float(lo), 4)))168    return paires169170171def echantillonner_raster(chemin_tif: Path, coords: list[tuple[float, float]]):172    """Échantillonne un GeoTIFF aux points (lat,lng) WGS84, par fenêtres173    (rasterio.sample), sans jamais charger le raster entier. On reprojette les174    POINTS vers le CRS du raster (EPSG:32198 Québec Lambert pour les deux175    rasters INSPQ), jamais le raster. Renvoie une liste de valeurs (None si176    nodata ou hors emprise)."""177    import math178179    import rasterio180    from pyproj import Transformer181182    with rasterio.open(chemin_tif) as ds:183        transfo = Transformer.from_crs("EPSG:4326", ds.crs, always_xy=True)184        xy = [transfo.transform(lo, la) for la, lo in coords]185        valeurs = []186        for echantillon in ds.sample(xy, indexes=1, masked=True):187            v = echantillon[0]188            if v is None or getattr(v, "mask", False) or math.isnan(float(v)) \189                    or (ds.nodata is not None and float(v) == float(ds.nodata)):190                valeurs.append(None)191            else:192                valeurs.append(float(v))193    return valeurs194195196def etape_chaleur(conn: sqlite3.Connection, cache: Path,197                  coords: list[tuple[float, float]] | None) -> None:198    print("== Étape chaleur (INSPQ ICFU 2020-2022) ==")199    if coords is None:200        coords = coords_depuis_louka()201    print(f"  {len(coords)} coordonnées à échantillonner")202    if not coords:203        return204205    tif_classes = cache / "ICFU2022_CentresPopulation2021_buf2000m_9cl.tif"206    tif_ecart = cache / "EcartTemperatureRelatif2022_Ecoumene2021.tif"207    telecharger(URL_TIF_CLASSES, tif_classes, "raster classes ICFU (≈34 Mo)")208    telecharger(URL_TIF_ECART, tif_ecart, "raster écarts de température (≈9,6 Go)")209210    print("  échantillonnage classes (9 niveaux) …")211    classes = echantillonner_raster(tif_classes, coords)212    print("  échantillonnage écarts de température …")213    ecarts = echantillonner_raster(tif_ecart, coords)214215    lignes = []216    for (la, lo), cl, ec in zip(coords, classes, ecarts):217        lignes.append((coord_key(la, lo),218                       int(cl) if cl is not None else None,219                       round(ec, 2) if ec is not None else None))220    conn.executemany(221        "INSERT OR REPLACE INTO heat(coord_key, classe, ecart) VALUES(?,?,?)",222        lignes)223224    poser_meta(conn, "heat.source",225               "INSPQ/CERFO — Îlots de chaleur/fraîcheur urbains et écarts de "226               "température relatifs 2020-2022 (Données Québec)")227    poser_meta(conn, "heat.url_classes", URL_TIF_CLASSES)228    poser_meta(conn, "heat.url_ecart", URL_TIF_ECART)229    poser_meta(conn, "heat.licence", "CC-BY 4.0 — attribution : INSPQ et CERFO")230    poser_meta(conn, "heat.sens_classes",231               "Vérifié dans les métadonnées Données Québec : classes 1-2-3 = "232               "îlots de fraîcheur urbains (1 = le plus frais), classes 8-9 = "233               "îlots de chaleur urbains (9 = le plus chaud). Classement des "234               "écarts de température par centre de population (2021), "235               "buffer 2 km, résolution 15 m, EPSG:32198.")236    poser_meta(conn, "heat.ecart_unite",237               "°C, écart relatif à un boisé de proximité (écoumène 2021) ; "238               "NULL = hors écoumène / hors centres de population (nodata)")239    poser_meta(conn, "heat.coord_key",240               "printf('%.4f',ROUND(lat,4))||','||printf('%.4f',ROUND(lng,4))")241    poser_meta(conn, "heat.telecharge_le", datetime.now().isoformat(timespec="seconds"))242    conn.commit()243    total, avec_classe = conn.execute(244        "SELECT COUNT(*), COUNT(classe) FROM heat").fetchone()245    print(f"  heat : {total} lignes ({avec_classe} avec classe)")246247248# --- Étape 2 : criminalité SPVM --------------------------------------------------249def etape_crime(conn: sqlite3.Connection, cache: Path) -> None:250    print("== Étape criminalité (actes criminels SPVM) ==")251    fichier = cache / "actes-criminels.csv"252    # Toujours rafraîchir : le jeu est mis à jour quotidiennement.253    if fichier.exists():254        fichier.unlink()255    telecharger(URL_CRIME_CSV, fichier, "CSV actes criminels (≈30 Mo)")256257    seuil = date.today() - timedelta(days=MOIS_CRIME * 365 // 12)258    lignes = []259    ecartees = 0260    with open(fichier, encoding="utf-8-sig", newline="") as f:261        for rang in csv.DictReader(f):262            try:263                d = datetime.strptime(rang["DATE"].strip(), "%Y-%m-%d").date()264                la = float(rang["LATITUDE"])265                lo = float(rang["LONGITUDE"])266            except (ValueError, KeyError, TypeError):267                ecartees += 1268                continue269            if d < seuil:270                continue271            # bornes de validité : île de Montréal et environs272            if not (44.5 <= la <= 46.5 and -75.0 <= lo <= -72.5):273                ecartees += 1274                continue275            ts = float(calendar.timegm(d.timetuple()))  # epoch UTC (minuit)276            lignes.append((la, lo, ts, rang["CATEGORIE"].strip()))277278    conn.execute("DELETE FROM crime_mtl")279    conn.executemany(280        "INSERT INTO crime_mtl(lat, lng, ts, categorie) VALUES(?,?,?,?)", lignes)281    poser_meta(conn, "crime.source",282               "Ville de Montréal (SPVM) — Actes criminels, "283               "https://donnees.montreal.ca/dataset/actes-criminels")284    poser_meta(conn, "crime.licence", "CC-BY 4.0 — attribution : Ville de Montréal")285    poser_meta(conn, "crime.fenetre", f"{MOIS_CRIME} derniers mois (depuis {seuil})")286    poser_meta(conn, "crime.telecharge_le", datetime.now().isoformat(timespec="seconds"))287    conn.commit()288    n = conn.execute("SELECT COUNT(*) FROM crime_mtl").fetchone()[0]289    print(f"  crime_mtl : {n} actes conservés (fenêtre ≥ {seuil}), "290          f"{ecartees} lignes écartées (coordonnées/date invalides)")291292293# --- Étape 3 : indice de gravité de la criminalité (StatCan) ---------------------294def lire_igc_csv(cache: Path, pid: int) -> dict[tuple[str, int], float]:295    """Extrait {(code_geo, annee): IGC} du CSV complet d'un tableau StatCan."""296    zip_path = cache / f"statcan_{pid}.zip"297    telecharger(URL_STATCAN_ZIP.format(pid=pid), zip_path, f"tableau StatCan {pid}")298    valeurs: dict[tuple[str, int], float] = {}299    with zipfile.ZipFile(zip_path) as z, \300            io.TextIOWrapper(z.open(f"{pid}.csv"), encoding="utf-8-sig") as f:301        for rang in csv.DictReader(f, delimiter=";"):302            if rang["Statistiques"] != "Indice de gravité de la criminalité":303                continue304            if not rang["VALEUR"]:305                continue306            geo = rang["GÉO"]307            code = geo.rsplit("[", 1)[-1].rstrip("]") if "[" in geo else \308                ("CAN" if geo == "Canada" else geo)309            valeurs[(code, int(rang["PÉRIODE DE RÉFÉRENCE"]))] = float(rang["VALEUR"])310    return valeurs311312313def taux_via_wds(requetes: list[tuple[int, int]]) -> dict[tuple[int, int], dict[int, float]]:314    """Taux de criminalité (Code criminel sauf délits de la route, /100 000 hab.)315    via l'API WDS. requetes = [(pid, geo_member)] ; renvoie316    {(pid, geo): {annee: taux}}."""317    charge = [{"productId": pid,318               "coordinate": f"{geo}.3.2.0.0.0.0.0.0.0",319               "latestN": ANNEES_IGC}320              for pid, geo in requetes]321    req = urllib.request.Request(322        URL_WDS, data=json.dumps(charge).encode(),323        headers={"Content-Type": "application/json", "User-Agent": UA})324    with urllib.request.urlopen(req, timeout=120) as rep:325        reponses = json.load(rep)326    resultat: dict[tuple[int, int], dict[int, float]] = {}327    for rep_item in reponses:328        if rep_item.get("status") != "SUCCESS":329            continue330        obj = rep_item["object"]331        geo = int(obj["coordinate"].split(".")[0])332        serie = {}333        for pt in obj["vectorDataPoint"]:334            if pt.get("value") is None:335                continue336            serie[int(pt["refPer"][:4])] = float(pt["value"])337        resultat[(obj["productId"], geo)] = serie338    return resultat339340341def etape_igc(conn: sqlite3.Connection, cache: Path) -> None:342    print("== Étape IGC (StatCan 35-10-0187 / 35-10-0026 + taux WDS) ==")343    igc_187 = lire_igc_csv(cache, 35100187)344    igc_026 = lire_igc_csv(cache, 35100026)345    indices = {**igc_026, **igc_187}346347    annees = sorted({a for (_, a) in igc_187})[-ANNEES_IGC:]348    print(f"  années retenues : {annees}")349350    try:351        taux = taux_via_wds(sorted({(s[3], s[4]) for s in SERVICES}))352    except Exception as exc:  # le taux est optionnel (NULL si indisponible)353        print(f"  avertissement : taux WDS indisponible ({exc}) — taux=NULL")354        taux = {}355356    conn.execute("DELETE FROM igc")357    for nom, code_geo, _pid_ind, pid_taux, geo_taux in SERVICES:358        for annee in annees:359            indice = indices.get((code_geo, annee))360            t = taux.get((pid_taux, geo_taux), {}).get(annee)361            conn.execute("INSERT INTO igc(service, annee, indice, taux) "362                         "VALUES(?,?,?,?)", (nom, annee, indice, t))363364    poser_meta(conn, "igc.source",365               "Statistique Canada, tableaux 35-10-0187 (IGC par service de "366               "police), 35-10-0026 (IGC Canada/provinces), 35-10-0179 et "367               "35-10-0177 (taux de criminalité — Code criminel sauf délits "368               "de la route, par 100 000 hab., via l'API WDS)")369    poser_meta(conn, "igc.licence",370               "Licence ouverte de Statistique Canada — adapté de Statistique "371               "Canada ; cela ne constitue pas une approbation par Statistique "372               "Canada de ce produit")373    poser_meta(conn, "igc.telecharge_le", datetime.now().isoformat(timespec="seconds"))374    conn.commit()375    n = conn.execute("SELECT COUNT(*) FROM igc").fetchone()[0]376    print(f"  igc : {n} lignes")377378379# --- Point d'entrée ---------------------------------------------------------------380def principal() -> int:381    ap = argparse.ArgumentParser(382        description="Construit data/staging-environnement.db (chaleur, "383                    "criminalité, IGC).")384    ap.add_argument("--etapes", default="chaleur,crime,igc",385                    help="étapes à exécuter, séparées par des virgules "386                         "(chaleur, crime, igc) — défaut : toutes")387    ap.add_argument("--coords", type=Path, default=None,388                    help="fichier de nouvelles coordonnées (« lat,lng » par "389                         "ligne) à échantillonner et upserter dans heat, au "390                         "lieu de relire louka.db")391    ap.add_argument("--cache", type=Path, default=CACHE_DEFAUT,392                    help=f"répertoire de cache des téléchargements "393                         f"(défaut : {CACHE_DEFAUT})")394    ap.add_argument("--garder-cache", action="store_true",395                    help="ne pas supprimer les GeoTIFF (~9,6 Go) à la fin")396    args = ap.parse_args()397398    etapes = {e.strip() for e in args.etapes.split(",")}399    inconnues = etapes - {"chaleur", "crime", "igc"}400    if inconnues:401        ap.error(f"étapes inconnues : {inconnues}")402403    args.cache.mkdir(parents=True, exist_ok=True)404    conn = ouvrir_staging()405    poser_meta(conn, "generation.script", "scripts/build_environnement.py")406    poser_meta(conn, "generation.date", datetime.now().isoformat(timespec="seconds"))407    poser_meta(conn, "attributions",408               "Îlots de chaleur : INSPQ et CERFO (CC-BY 4.0) ; Actes "409               "criminels : Ville de Montréal (CC-BY 4.0) ; IGC et taux de "410               "criminalité : Statistique Canada (licence ouverte)")411412    coords = coords_depuis_fichier(args.coords) if args.coords else None413    if "chaleur" in etapes:414        etape_chaleur(conn, args.cache, coords)415    if "crime" in etapes:416        etape_crime(conn, args.cache)417    if "igc" in etapes:418        etape_igc(conn, args.cache)419    conn.commit()420    conn.close()421422    if not args.garder_cache:423        for tif in args.cache.glob("*.tif"):424            print(f"  nettoyage : suppression {tif}")425            tif.unlink()426427    print("Terminé :", DB_STAGING)428    return 0429430431if __name__ == "__main__":432    sys.exit(principal())433