SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
9.3 KB · 230 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de propriétés à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# quartier.py : statistiques de quartier par annonce (à la Centris, en libre)5#   Base statique data/quartier.db construite par scripts/build_*.py :6#     - da_poly / da_stats : aires de diffusion 2021 + profil du recensement7#     - da_pmd : mesures de proximité StatCan (scores 0..1)8#     - da_defav : défavorisation matérielle/sociale INSPQ (quintiles)9#     - heat : classe d'îlot de chaleur/fraîcheur INSPQ par immeuble10#     - crime_mtl / igc : actes criminels SPVM (points) + indice de gravité11#   Jointure : lat/lng -> DAUID par point-dans-polygone local (préfiltre bbox),12#   mémorisée dans listings.dauid à l'enrichissement (boucle watch).13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import math18import sqlite319import time20from pathlib import Path2122from . import db2324QUARTIER_DB = Path(__file__).resolve().parent.parent / "data" / "quartier.db"2526# villes couvertes par les points SPVM (agglomération de Montréal)27_VILLES_SPVM = {"montreal", "montreal-est", "montreal-ouest", "westmount",28                "cote saint-luc", "cote-saint-luc", "hampstead", "mont-royal",29                "outremont", "verdun", "lasalle", "lachine", "anjou",30                "saint-leonard", "saint-laurent", "ahuntsic", "dorval",31                "pointe-claire", "kirkland", "beaconsfield", "dollard-des-ormeaux"}3233# correspondance ville -> fragment du nom de service dans la table igc34_IGC_SERVICE = {35    "quebec": "SPVQ", "levis": "Lévis", "montreal": "SPVM",36    "laval": "Laval", "longueuil": "Longueuil",37}383940def disponible() -> bool:41    return QUARTIER_DB.exists()424344def _connect() -> sqlite3.Connection:45    con = sqlite3.connect(f"file:{QUARTIER_DB}?mode=ro", uri=True)46    con.row_factory = sqlite3.Row47    return con484950# ---------------------------------------------------------------------------51# lat/lng -> DAUID (point dans polygone, préfiltre bbox)52# ---------------------------------------------------------------------------5354def _dans_anneau(lat: float, lng: float, anneau: list) -> bool:55    """Lancer de rayon (even-odd). anneau = [[lng, lat], ...]."""56    dedans = False57    n = len(anneau)58    j = n - 159    for i in range(n):60        xi, yi = anneau[i][0], anneau[i][1]61        xj, yj = anneau[j][0], anneau[j][1]62        if (yi > lat) != (yj > lat) and \63                lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi:64            dedans = not dedans65        j = i66    return dedans676869def dauid_for(qcon: sqlite3.Connection, lat: float, lng: float) -> str | None:70    rows = qcon.execute(71        "SELECT dauid, poly FROM da_poly WHERE lat_min<=? AND lat_max>=?"72        " AND lng_min<=? AND lng_max>=?", (lat, lat, lng, lng)).fetchall()73    for r in rows:74        anneaux = json.loads(r["poly"])75        # even-odd sur tous les anneaux (les trous annulent)76        compte = sum(1 for a in anneaux if _dans_anneau(lat, lng, a))77        if compte % 2 == 1:78            return r["dauid"]79    return None808182# ---------------------------------------------------------------------------83# Assemblage pour la fiche84# ---------------------------------------------------------------------------8586def _cle_ville(city: str) -> str:87    import unicodedata88    s = "".join(c for c in unicodedata.normalize("NFD", city or "")89                if unicodedata.category(c) != "Mn")90    return s.strip().lower()919293def _crime_mtl(qcon: sqlite3.Connection, lat: float, lng: float) -> dict | None:94    """Comptage des actes criminels SPVM à < 500 m : 12 mois vs 12 précédents."""95    dlat = 500 / 111000.096    dlng = 500 / (111000.0 * max(0.2, math.cos(math.radians(lat))))97    now = time.time()98    rows = qcon.execute(99        "SELECT lat, lng, ts, categorie FROM crime_mtl WHERE lat BETWEEN ? AND ?"100        " AND lng BETWEEN ? AND ? AND ts >= ?",101        (lat - dlat, lat + dlat, lng - dlng, lng + dlng, now - 730 * 86400)).fetchall()102    recent = avant = 0103    cats: dict[str, list[int]] = {}          # categorie -> [12 mois, 12 prec.]104    for r in rows:105        # distance exacte (le bbox est un carré)106        d = math.hypot((r["lat"] - lat) * 111000.0,107                       (r["lng"] - lng) * 111000.0 * math.cos(math.radians(lat)))108        if d > 500:109            continue110        c = cats.setdefault(r["categorie"] or "Autre", [0, 0])111        if r["ts"] >= now - 365 * 86400:112            recent += 1113            c[0] += 1114        else:115            avant += 1116            c[1] += 1117    if recent == 0 and avant == 0:118        return None119    categories = [{"nom": k, "n": v[0], "n_prec": v[1]}120                  for k, v in sorted(cats.items(),121                                     key=lambda kv: -(kv[1][0] + kv[1][1]))]122    return {"type": "points", "rayon_m": 500, "douze_mois": recent,123            "douze_mois_precedents": avant, "categories": categories}124125126def _crime_igc(qcon: sqlite3.Connection, city: str) -> dict | None:127    service = _IGC_SERVICE.get(_cle_ville(city))128    if not service:129        return None130    row = qcon.execute(131        "SELECT annee, indice FROM igc WHERE service LIKE '%' || ? || '%'"132        " ORDER BY annee DESC LIMIT 1", (service,)).fetchone()133    if row is None or row["indice"] is None:134        return None135    ref = qcon.execute(136        "SELECT indice FROM igc WHERE service LIKE '%canada%' AND annee=?",137        (row["annee"],)).fetchone()138    return {"type": "igc", "ville": city, "annee": row["annee"],139            "indice": round(row["indice"], 1),140            "indice_canada": round(ref["indice"], 1) if ref and ref["indice"] else None}141142143def fiche_quartier(lat: float | None, lng: float | None, city: str,144                   dauid: str | None = None) -> dict | None:145    """Bloc « Le quartier » d'une fiche. None si données indisponibles."""146    if not disponible() or lat is None or lng is None:147        return None148    qcon = _connect()149    try:150        if not dauid:151            dauid = dauid_for(qcon, lat, lng)152        out: dict = {"dauid": dauid}153154        if dauid:155            r = qcon.execute("SELECT * FROM da_stats WHERE dauid=?", (dauid,)).fetchone()156            if r:157                out["demographie"] = {k: r[k] for k in158                                      ("population", "densite", "age_median",159                                       "revenu_median", "pct_locataires",160                                       "loyer_moyen", "pct_francais", "pct_univ")}161            # rangs centiles québécois (0-100) — voir scripts/merge_quartier.py162            r = qcon.execute("SELECT * FROM da_pmd_pct WHERE dauid=?", (dauid,)).fetchone()163            if r:164                out["proximite"] = {k: r[k] / 100.0 for k in r.keys()165                                    if k != "dauid" and r[k] is not None}166            r = qcon.execute("SELECT quintile_materiel, quintile_social FROM da_defav"167                             " WHERE dauid=?", (dauid,)).fetchone()168            if r:169                out["defavorisation"] = dict(r)170171        # îlot de chaleur : coordonnée exacte, sinon la plus proche (~120 m)172        key = f"{round(lat, 4)},{round(lng, 4)}"173        r = qcon.execute("SELECT classe, ecart FROM heat WHERE coord_key=?",174                         (key,)).fetchone()175        if r is None:176            r = qcon.execute(177                "SELECT classe, ecart FROM heat WHERE coord_key LIKE ?"178                " AND classe IS NOT NULL LIMIT 1",179                (f"{round(lat, 3)}%",)).fetchone()180        if r and r["classe"] is not None:181            out["chaleur"] = {"classe": r["classe"], "ecart": r["ecart"]}182183        # criminalité : points SPVM sur l'île, indice IGC ailleurs184        crime = None185        if _cle_ville(city) in _VILLES_SPVM:186            crime = _crime_mtl(qcon, lat, lng)187        if crime is None:188            crime = _crime_igc(qcon, city)189        if crime:190            out["crime"] = crime191192        return out if len(out) > 1 else None193    except sqlite3.Error:194        return None195    finally:196        qcon.close()197198199# ---------------------------------------------------------------------------200# Enrichissement : mémoriser le DAUID de chaque annonce (boucle watch)201# ---------------------------------------------------------------------------202203def enrich(limit: int | None = None) -> dict:204    """Remplit listings.dauid pour les annonces géolocalisées qui ne l'ont pas."""205    if not disponible():206        print("[immo-ka] quartier: data/quartier.db absent — étape sautée")207        return {"enriched": 0, "missing_db": True}208    con = db.connect()209    qcon = _connect()210    rows = con.execute(211        "SELECT uid, lat, lng FROM listings WHERE active=1 AND lat IS NOT NULL"212        " AND (dauid IS NULL OR dauid='')").fetchall()213    if limit is not None:214        rows = rows[:limit]215    done = introuvable = 0216    for r in rows:217        d = dauid_for(qcon, r["lat"], r["lng"])218        con.execute("UPDATE listings SET dauid=? WHERE uid=?",219                    (d or "hors-zone", r["uid"]))220        if d:221            done += 1222        else:223            introuvable += 1224    con.commit()225    qcon.close()226    con.close()227    stats = {"enriched": done, "hors_zone": introuvable, "candidats": len(rows)}228    print(f"[immo-ka] quartier {stats}")229    return stats230