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%
6.9 KB · 186 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# 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