Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# environment.py : données d'environnement OpenStreetMap par tuile (Overpass)5# pour le calcul des KA Scores (kascores.py). Même stratégie « par tuiles »6# que poi.py (0,5° + marge) mais avec un inventaire élargi :7# · routes majeures (autoroutes/artères) et voies ferrées AVEC géométrie8# (nœuds) — distances aux sources de bruit du KA Calme Score ;9# · aéroports/héliports, zones industrielles, bars/boîtes de nuit ;10# · pistes cyclables (géométrie) — densité du KA Bike Score ;11# · TOUS les points d'intérêt des catégories poi.py — plus proches ET12# comptages par rayon (KA Walk/Services Scores).13# Cache permanent en base (table env_tiles), rafraîchi aux ~3 mois.14# Attribution : données © contributeurs OpenStreetMap (ODbL).15# -----------------------------------------------------------------------------16from __future__ import annotations1718import json19import math20import time2122from . import db23from .poi import (24 CATEGORIES, OVERPASS_URLS, PoiClient, TILE, TILE_MARGIN, _match_category,25 _tile_of,26)2728REFRESH_AFTER = 90 * 86400 # l'environnement bâti bouge peu2930# Classes linéaires (bruit / vélo) : (clé, sélecteur Overpass)31_LINEAR = [32 ("autoroute", '["highway"~"^(motorway|motorway_link|trunk)$"]'),33 ("artere", '["highway"~"^(primary|secondary)$"]'),34 ("rail", '["railway"~"^(rail|light_rail)$"]["service"!~"."]'),35 ("cyclable", '["highway"="cycleway"]'),36 ("cyclable2", '["cycleway"~"^(lane|track|opposite_lane|opposite_track)$"]'),37]3839# Classes ponctuelles additionnelles (bruit / vie nocturne)40_POINTS = [41 ("aeroport", '["aeroway"~"^(aerodrome|heliport)$"]'),42 ("industriel", '["landuse"="industrial"]'),43 ("bar", '["amenity"~"^(bar|nightclub|pub)$"]'),44]454647def _tile_bbox(ty: int, tx: int) -> str:48 s = ty * TILE - TILE_MARGIN49 n = (ty + 1) * TILE + TILE_MARGIN50 w = tx * TILE - TILE_MARGIN51 e = (tx + 1) * TILE + TILE_MARGIN52 return f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"535455def _linear_query(ty: int, tx: int) -> str:56 bbox = _tile_bbox(ty, tx)57 parts = [f"way{sel}({bbox});" for _k, sel in _LINEAR]58 return f'[out:json][timeout:240];({"".join(parts)});out geom;'596061def _points_query(ty: int, tx: int) -> str:62 bbox = _tile_bbox(ty, tx)63 parts = [f"nwr{sel}({bbox});" for _k, sel in _POINTS]64 parts += [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES]65 return f'[out:json][timeout:240];({"".join(parts)});out center tags;'666768def _match_linear(tags: dict) -> str | None:69 hw = tags.get("highway")70 if hw in ("motorway", "motorway_link", "trunk"):71 return "autoroute"72 if hw in ("primary", "secondary"):73 return "artere"74 if tags.get("railway") in ("rail", "light_rail"):75 return "rail"76 if hw == "cycleway" or tags.get("cycleway") in (77 "lane", "track", "opposite_lane", "opposite_track"):78 return "cyclable"79 return None808182def _match_point(tags: dict) -> str | None:83 if tags.get("aeroway") in ("aerodrome", "heliport"):84 return "aeroport"85 if tags.get("landuse") == "industrial":86 return "industriel"87 if tags.get("amenity") in ("bar", "nightclub", "pub"):88 return "bar"89 return None909192def fetch_tile(client: PoiClient, ty: int, tx: int) -> dict | None:93 """Inventaire environnemental d'une tuile.9495 Format : {"lines": {classe: [[[lat,lng],…] par voie]},96 "points": {classe: [[lat,lng],…]},97 "pois": {cat: [[lat,lng],…]}}98 """99 lines_raw = client._post(_linear_query(ty, tx))100 if lines_raw is None:101 return None102 points_raw = client._post(_points_query(ty, tx))103 if points_raw is None:104 return None105106 lines: dict[str, list] = {}107 for el in lines_raw:108 tags = el.get("tags") or {}109 cls = _match_linear(tags)110 geom = el.get("geometry") or []111 if cls is None or len(geom) < 2:112 continue113 # nœuds arrondis à 5 décimales (~1 m) — suffisant pour des distances114 lines.setdefault(cls, []).append(115 [[round(g["lat"], 5), round(g["lon"], 5)] for g in geom])116117 points: dict[str, list] = {}118 pois: dict[str, list] = {}119 for el in points_raw:120 tags = el.get("tags") or {}121 lat = el.get("lat") or (el.get("center") or {}).get("lat")122 lng = el.get("lon") or (el.get("center") or {}).get("lon")123 if lat is None or lng is None:124 continue125 pt = [round(lat, 5), round(lng, 5)]126 cls = _match_point(tags)127 if cls is not None:128 points.setdefault(cls, []).append(pt)129 cat = _match_category(tags)130 if cat is not None:131 pois.setdefault(cat, []).append(pt)132133 return {"lines": lines, "points": points, "pois": pois}134135136def needed_tiles(con) -> list[tuple[int, int]]:137 """Tuiles couvrant les immeubles géolocalisés du parc actif."""138 rows = con.execute(139 """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings140 WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall()141 return sorted({_tile_of(r["la"], r["ln"]) for r in rows})142143144def run(limit: int | None = None) -> dict:145 """Remplit/rafraîchit env_tiles pour toutes les tuiles du parc.146147 `limit` borne le nombre de tuiles téléchargées cette fois-ci (2 requêtes148 Overpass par tuile ; les tuiles fraîches ne coûtent rien).149 """150 con = db.connect()151 client = PoiClient()152 tiles = needed_tiles(con)153 now = time.time()154 done = fetched = failed = 0155 for ty, tx in tiles:156 key = f"{ty},{tx}"157 row = con.execute("SELECT fetched_at FROM env_tiles WHERE tile_key=?",158 (key,)).fetchone()159 if row is not None and now - row["fetched_at"] < REFRESH_AFTER:160 done += 1161 continue162 if limit is not None and fetched >= limit:163 continue164 data = fetch_tile(client, ty, tx)165 if data is None:166 failed += 1167 print(f" ✗ tuile {key} : Overpass indisponible")168 continue169 con.execute(170 "INSERT OR REPLACE INTO env_tiles(tile_key, data, fetched_at)"171 " VALUES (?,?,?)", (key, json.dumps(data), time.time()))172 con.commit()173 fetched += 1174 n_lines = sum(len(v) for v in data["lines"].values())175 n_pois = sum(len(v) for v in data["pois"].values())176 print(f" ✓ tuile {key} : {n_lines} voies, {n_pois} POI")177 con.close()178 return {"tuiles": len(tiles), "fraiches": done, "telechargees": fetched,179 "echecs": failed}180181182def load_tile(con, ty: int, tx: int) -> dict | None:183 row = con.execute("SELECT data FROM env_tiles WHERE tile_key=?",184 (f"{ty},{tx}",)).fetchone()185 return json.loads(row["data"]) if row else None186