# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # environment.py : données d'environnement OpenStreetMap par tuile (Overpass) # pour le calcul des KA Scores (kascores.py). Même stratégie « par tuiles » # que poi.py (0,5° + marge) mais avec un inventaire élargi : # · routes majeures (autoroutes/artères) et voies ferrées AVEC géométrie # (nœuds) — distances aux sources de bruit du KA Calme Score ; # · aéroports/héliports, zones industrielles, bars/boîtes de nuit ; # · pistes cyclables (géométrie) — densité du KA Bike Score ; # · TOUS les points d'intérêt des catégories poi.py — plus proches ET # comptages par rayon (KA Walk/Services Scores). # Cache permanent en base (table env_tiles), rafraîchi aux ~3 mois. # Attribution : données © contributeurs OpenStreetMap (ODbL). # ----------------------------------------------------------------------------- from __future__ import annotations import json import math import time from . import db from .poi import ( CATEGORIES, OVERPASS_URLS, PoiClient, TILE, TILE_MARGIN, _match_category, _tile_of, ) REFRESH_AFTER = 90 * 86400 # l'environnement bâti bouge peu # Classes linéaires (bruit / vélo) : (clé, sélecteur Overpass) _LINEAR = [ ("autoroute", '["highway"~"^(motorway|motorway_link|trunk)$"]'), ("artere", '["highway"~"^(primary|secondary)$"]'), ("rail", '["railway"~"^(rail|light_rail)$"]["service"!~"."]'), ("cyclable", '["highway"="cycleway"]'), ("cyclable2", '["cycleway"~"^(lane|track|opposite_lane|opposite_track)$"]'), ] # Classes ponctuelles additionnelles (bruit / vie nocturne) _POINTS = [ ("aeroport", '["aeroway"~"^(aerodrome|heliport)$"]'), ("industriel", '["landuse"="industrial"]'), ("bar", '["amenity"~"^(bar|nightclub|pub)$"]'), ] def _tile_bbox(ty: int, tx: int) -> str: s = ty * TILE - TILE_MARGIN n = (ty + 1) * TILE + TILE_MARGIN w = tx * TILE - TILE_MARGIN e = (tx + 1) * TILE + TILE_MARGIN return f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}" def _linear_query(ty: int, tx: int) -> str: bbox = _tile_bbox(ty, tx) parts = [f"way{sel}({bbox});" for _k, sel in _LINEAR] return f'[out:json][timeout:240];({"".join(parts)});out geom;' def _points_query(ty: int, tx: int) -> str: bbox = _tile_bbox(ty, tx) parts = [f"nwr{sel}({bbox});" for _k, sel in _POINTS] parts += [f"nwr{sel}({bbox});" for _c, _l, sel, _r in CATEGORIES] return f'[out:json][timeout:240];({"".join(parts)});out center tags;' def _match_linear(tags: dict) -> str | None: hw = tags.get("highway") if hw in ("motorway", "motorway_link", "trunk"): return "autoroute" if hw in ("primary", "secondary"): return "artere" if tags.get("railway") in ("rail", "light_rail"): return "rail" if hw == "cycleway" or tags.get("cycleway") in ( "lane", "track", "opposite_lane", "opposite_track"): return "cyclable" return None def _match_point(tags: dict) -> str | None: if tags.get("aeroway") in ("aerodrome", "heliport"): return "aeroport" if tags.get("landuse") == "industrial": return "industriel" if tags.get("amenity") in ("bar", "nightclub", "pub"): return "bar" return None def fetch_tile(client: PoiClient, ty: int, tx: int) -> dict | None: """Inventaire environnemental d'une tuile. Format : {"lines": {classe: [[[lat,lng],…] par voie]}, "points": {classe: [[lat,lng],…]}, "pois": {cat: [[lat,lng],…]}} """ lines_raw = client._post(_linear_query(ty, tx)) if lines_raw is None: return None points_raw = client._post(_points_query(ty, tx)) if points_raw is None: return None lines: dict[str, list] = {} for el in lines_raw: tags = el.get("tags") or {} cls = _match_linear(tags) geom = el.get("geometry") or [] if cls is None or len(geom) < 2: continue # nœuds arrondis à 5 décimales (~1 m) — suffisant pour des distances lines.setdefault(cls, []).append( [[round(g["lat"], 5), round(g["lon"], 5)] for g in geom]) points: dict[str, list] = {} pois: dict[str, list] = {} for el in points_raw: tags = el.get("tags") or {} lat = el.get("lat") or (el.get("center") or {}).get("lat") lng = el.get("lon") or (el.get("center") or {}).get("lon") if lat is None or lng is None: continue pt = [round(lat, 5), round(lng, 5)] cls = _match_point(tags) if cls is not None: points.setdefault(cls, []).append(pt) cat = _match_category(tags) if cat is not None: pois.setdefault(cat, []).append(pt) return {"lines": lines, "points": points, "pois": pois} def needed_tiles(con) -> list[tuple[int, int]]: """Tuiles couvrant les immeubles géolocalisés du parc actif.""" rows = con.execute( """SELECT DISTINCT ROUND(lat,4) la, ROUND(lng,4) ln FROM listings WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL""").fetchall() return sorted({_tile_of(r["la"], r["ln"]) for r in rows}) def run(limit: int | None = None) -> dict: """Remplit/rafraîchit env_tiles pour toutes les tuiles du parc. `limit` borne le nombre de tuiles téléchargées cette fois-ci (2 requêtes Overpass par tuile ; les tuiles fraîches ne coûtent rien). """ con = db.connect() client = PoiClient() tiles = needed_tiles(con) now = time.time() done = fetched = failed = 0 for ty, tx in tiles: key = f"{ty},{tx}" row = con.execute("SELECT fetched_at FROM env_tiles WHERE tile_key=?", (key,)).fetchone() if row is not None and now - row["fetched_at"] < REFRESH_AFTER: done += 1 continue if limit is not None and fetched >= limit: continue data = fetch_tile(client, ty, tx) if data is None: failed += 1 print(f" ✗ tuile {key} : Overpass indisponible") continue con.execute( "INSERT OR REPLACE INTO env_tiles(tile_key, data, fetched_at)" " VALUES (?,?,?)", (key, json.dumps(data), time.time())) con.commit() fetched += 1 n_lines = sum(len(v) for v in data["lines"].values()) n_pois = sum(len(v) for v in data["pois"].values()) print(f" ✓ tuile {key} : {n_lines} voies, {n_pois} POI") con.close() return {"tuiles": len(tiles), "fraiches": done, "telechargees": fetched, "echecs": failed} def load_tile(con, ty: int, tx: int) -> dict | None: row = con.execute("SELECT data FROM env_tiles WHERE tile_key=?", (f"{ty},{tx}",)).fetchone() return json.loads(row["data"]) if row else None