spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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