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%
7.0 KB · 172 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# inondation.py : risque d'inondation à l'adresse — BDZI (gouv. du Québec)5#6#   Source : Base de données des zones à risque d'inondation (BDZI, MELCCFP),7#   donnée ouverte officielle (Données Québec), GeoPackage EPSG:3857.8#   `build()` éclate les multipolygones de la couche ZOI_s (pleine précision)9#   en polygones simples indexés R*Tree dans data/inondation.db (+ la couche10#   Carto_ZI_S : périmètres couverts par une cartographie — sans elle,11#   « aucune zone » ne veut rien dire), puis `lookup(lat, lng)` fait un test12#   point-dans-polygone exact (shapely) sur les seuls candidats de la boîte.13#14#   Types BDZI : « Zone de grand courant » (récurrence 0-20 ans, risque15#   élevé), « Zone de faible courant » (20-100 ans), « Zone de crue16#   0-100 ans », variantes « - Pont » et « Autre zone inondable ».17#18#   Usage : python run.py inondation-build   (une fois, GPKG requis)19#           lookup(lat, lng) -> dict (fiche, /api/inondation)20# -----------------------------------------------------------------------------21from __future__ import annotations2223import math24import sqlite325from pathlib import Path2627DATA = Path(__file__).resolve().parent.parent / "data"28DB_PATH = DATA / "inondation.db"29GPKG = DATA / "BDZI_GPK.gpkg"3031# rayon de tolérance : géocodage + emprise du bâtiment32NEAR_M = 30.033# au-delà, on signale quand même une zone toute proche (information utile)34WARN_M = 100.03536_SEVERITE = {37    "Zone de grand courant": ("eleve", "récurrence 0-20 ans"),38    "Zone de grand courant - Pont": ("eleve", "récurrence 0-20 ans"),39    "Zone de faible courant": ("modere", "récurrence 20-100 ans"),40    "Zone de faible courant - Pont": ("modere", "récurrence 20-100 ans"),41    "Zone de crue 0-100 ans": ("present", "récurrence 0-100 ans"),42    "Zone de crue 0-100 ans - Pont": ("present", "récurrence 0-100 ans"),43    "Autre zone inondable": ("present", ""),44}4546_R = 20037508.342789244474849def _to_3857(lat: float, lng: float) -> tuple[float, float]:50    x = lng * _R / 180.051    y = math.log(math.tan((90 + lat) * math.pi / 360.0)) * _R / math.pi52    return x, y535455def _gpkg_wkb(blob: bytes) -> bytes:56    """Retire l'en-tête GeoPackage (magic GP + drapeaux + enveloppe)."""57    if blob[:2] != b"GP":58        return blob59    flags = blob[3]60    env = (flags >> 1) & 0x0761    env_len = {0: 0, 1: 32, 2: 48, 3: 48, 4: 64}.get(env, 0)62    return blob[8 + env_len:]636465def build(gpkg: Path = GPKG) -> None:66    """Construit data/inondation.db à partir du GeoPackage BDZI."""67    from shapely import wkb as _swkb6869    src = sqlite3.connect(gpkg)70    con = sqlite3.connect(DB_PATH)71    con.executescript("""72        DROP TABLE IF EXISTS zi; DROP TABLE IF EXISTS zi_rtree;73        DROP TABLE IF EXISTS couverture; DROP TABLE IF EXISTS couv_rtree;74        CREATE TABLE zi (id INTEGER PRIMARY KEY, description TEXT,75                         rapport TEXT, date_rapport TEXT, wkb BLOB);76        CREATE VIRTUAL TABLE zi_rtree USING rtree(id, xmin, xmax, ymin, ymax);77        CREATE TABLE couverture (id INTEGER PRIMARY KEY, nom TEXT, wkb BLOB);78        CREATE VIRTUAL TABLE couv_rtree USING rtree(id, xmin, xmax, ymin, ymax);79    """)80    nid = 081    for desc, rapport, date_r, blob in src.execute(82            "SELECT Description, Nm_rapport, Date_rapport, Shape FROM ZOI_s"):83        geom = _swkb.loads(_gpkg_wkb(blob))84        polys = geom.geoms if geom.geom_type == "MultiPolygon" else [geom]85        for poly in polys:86            if poly.is_empty:87                continue88            nid += 189            con.execute("INSERT INTO zi VALUES (?,?,?,?,?)",90                        (nid, desc, rapport, date_r, poly.wkb))91            x0, y0, x1, y1 = poly.bounds92            con.execute("INSERT INTO zi_rtree VALUES (?,?,?,?,?)",93                        (nid, x0, x1, y0, y1))94        if nid % 500 < len(polys):95            print(f"[inondation] {nid} polygones…", flush=True)96    cid = 097    for nom, blob in src.execute("SELECT Nom_Carte, Shape FROM Carto_ZI_S"):98        geom = _swkb.loads(_gpkg_wkb(blob))99        polys = geom.geoms if geom.geom_type == "MultiPolygon" else [geom]100        for poly in polys:101            if poly.is_empty:102                continue103            cid += 1104            con.execute("INSERT INTO couverture VALUES (?,?,?)",105                        (cid, nom, poly.wkb))106            x0, y0, x1, y1 = poly.bounds107            con.execute("INSERT INTO couv_rtree VALUES (?,?,?,?,?)",108                        (cid, x0, x1, y0, y1))109    con.commit()110    con.execute("VACUUM")111    print(f"[inondation] {nid} polygones de zones, {cid} périmètres "112          f"cartographiés -> {DB_PATH}")113    con.close()114    src.close()115116117def lookup(lat: float, lng: float) -> dict | None:118    """Risque d'inondation BDZI au point (WGS84). None si base absente."""119    if not DB_PATH.exists():120        return None121    from shapely import wkb as _swkb122    from shapely.geometry import Point123124    x, y = _to_3857(lat, lng)125    # les distances 3857 sont dilatées d'un facteur 1/cos(lat)126    scale = 1.0 / max(0.2, math.cos(math.radians(lat)))127    pad = WARN_M * scale128    pt = Point(x, y)129130    con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True)131    zones: list[dict] = []132    for zid, desc, rapport, date_r, blob in con.execute(133            "SELECT z.id, z.description, z.rapport, z.date_rapport, z.wkb "134            "FROM zi z JOIN zi_rtree r ON z.id = r.id "135            "WHERE r.xmax >= ? AND r.xmin <= ? AND r.ymax >= ? AND r.ymin <= ?",136            (x - pad, x + pad, y - pad, y + pad)):137        poly = _swkb.loads(blob)138        d = poly.distance(pt) / scale        # ~mètres réels139        if poly.contains(pt):140            d = 0.0141        elif d > WARN_M:142            continue143        sev, rec = _SEVERITE.get(desc, ("present", ""))144        zones.append({"type": desc, "severite": sev, "recurrence": rec,145                      "distance_m": round(d),146                      "date_rapport": (date_r or "")[:10] or None})147    couvert = False148    for (blob,) in con.execute(149            "SELECT c.wkb FROM couverture c JOIN couv_rtree r ON c.id = r.id "150            "WHERE r.xmax >= ? AND r.xmin <= ? AND r.ymax >= ? AND r.ymin <= ?",151            (x, x, y, y)):152        if _swkb.loads(blob).contains(pt):153            couvert = True154            break155    con.close()156157    zones.sort(key=lambda z: (z["distance_m"],158                              {"eleve": 0, "modere": 1, "present": 2}159                              .get(z["severite"], 3)))160    dans = [z for z in zones if z["distance_m"] <= NEAR_M]161    if dans:162        statut = "en_zone"163        pire = dans[0]["severite"]164    elif zones:165        statut, pire = "a_proximite", zones[0]["severite"]166    elif couvert:167        statut, pire = "hors_zone", None168    else:169        statut, pire = "non_cartographie", None170    return {"statut": statut, "severite": pire, "couvert": couvert,171            "zones": zones[:5]}172