Stats de quartier (à la Centris) — runtime + fiche « Le quartier »
- louka/quartier.py : jointure lat/lng -> aire de diffusion 2021 (point-dans- polygone local, préfiltre bbox), assemblage fiche (démographie recensement, scores de proximité StatCan, défavorisation INSPQ, îlot de chaleur, criminalité SPVM <500 m sur l'île / indice IGC ailleurs), enrichissement listings.dauid dans la boucle watch - data/quartier.db : base statique construite par scripts/ (fusion via scripts/merge_quartier.py), défensif si absente - fiche : section « Le quartier » (tuiles démographiques, barres de proximité 0-100, badges chaleur/criminalité, attributions licences ouvertes) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 changed files +495 −0
modified
frontend/src/api.ts
+21 −0
@@ -27,6 +27,26 @@ export interface Poi { | |||||
| 27 | dist_m: number; | 27 | dist_m: number; | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | +export interface Quartier { | ||||
| 31 | + dauid?: string | null; | ||||
| 32 | + demographie?: { | ||||
| 33 | + population: number | null; | ||||
| 34 | + densite: number | null; | ||||
| 35 | + age_median: number | null; | ||||
| 36 | + revenu_median: number | null; | ||||
| 37 | + pct_locataires: number | null; | ||||
| 38 | + loyer_moyen: number | null; | ||||
| 39 | + pct_francais: number | null; | ||||
| 40 | + pct_univ: number | null; | ||||
| 41 | + }; | ||||
| 42 | + proximite?: Record<string, number>; // scores 0..1 (PMD StatCan) | ||||
| 43 | + defavorisation?: { quintile_materiel: number | null; quintile_social: number | null }; | ||||
| 44 | + chaleur?: { classe: number; ecart: number | null }; // 1 fraîcheur … 9 chaleur | ||||
| 45 | + crime?: | ||||
| 46 | + | { type: "points"; rayon_m: number; douze_mois: number; douze_mois_precedents: number } | ||||
| 47 | + | { type: "igc"; ville: string; annee: number; indice: number; indice_canada: number | null }; | ||||
| 48 | +} | ||||
| 49 | + | ||||
| 30 | export interface Listing { | 50 | export interface Listing { | ||
| 31 | uid: string; | 51 | uid: string; | ||
| 32 | source: string; | 52 | source: string; | ||
@@ -51,6 +71,7 @@ export interface Listing { | |||||
| 51 | lat: number | null; | 71 | lat: number | null; | ||
| 52 | lng: number | null; | 72 | lng: number | null; | ||
| 53 | poi?: Poi[]; // commodités de proximité (fiche seulement) | 73 | poi?: Poi[]; // commodités de proximité (fiche seulement) | ||
| 74 | + quartier?: Quartier | null; // stats de quartier (fiche seulement) | ||||
| 54 | last_seen: number; | 75 | last_seen: number; | ||
| 55 | updated_at: number; | 76 | updated_at: number; | ||
| 56 | active: number; | 77 | active: number; | ||
added
frontend/src/components/QuartierBlock.tsx
+123 −0
@@ -0,0 +1,123 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// components/QuartierBlock.tsx : section « Le quartier » de la fiche — | ||||
| 5 | +// démographie du recensement 2021 (aire de diffusion ~500 hab.), scores de | ||||
| 6 | +// proximité StatCan, îlot de chaleur/fraîcheur INSPQ, criminalité. | ||||
| 7 | +// ----------------------------------------------------------------------------- | ||||
| 8 | +import { Quartier } from "../api"; | ||||
| 9 | + | ||||
| 10 | +const fmtMoney = (v: number | null | undefined) => | ||||
| 11 | + v == null ? null : v.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) + " $"; | ||||
| 12 | +const fmtPct = (v: number | null | undefined) => | ||||
| 13 | + v == null ? null : Math.round(v) + " %"; | ||||
| 14 | + | ||||
| 15 | +// scores PMD affichés (clé backend -> libellé) | ||||
| 16 | +const PROX_LABELS: [string, string][] = [ | ||||
| 17 | + ["prox_epicerie", "Épiceries"], | ||||
| 18 | + ["prox_transport", "Transport en commun"], | ||||
| 19 | + ["prox_parc", "Parcs"], | ||||
| 20 | + ["prox_ecole_prim", "Écoles primaires"], | ||||
| 21 | + ["prox_sante", "Soins de santé"], | ||||
| 22 | + ["prox_pharmacie", "Pharmacies"], | ||||
| 23 | +]; | ||||
| 24 | + | ||||
| 25 | +function chaleurBadge(classe: number, ecart: number | null) { | ||||
| 26 | + if (classe <= 3) | ||||
| 27 | + return { txt: "Îlot de fraîcheur", cls: "q-badge cool", ico: "🌿" }; | ||||
| 28 | + if (classe >= 7) | ||||
| 29 | + return { | ||||
| 30 | + txt: `Îlot de chaleur${ecart != null ? ` (+${ecart.toFixed(1)} °C)` : ""}`, | ||||
| 31 | + cls: "q-badge hot", ico: "🌡", | ||||
| 32 | + }; | ||||
| 33 | + return { txt: "Température de quartier moyenne", cls: "q-badge neutral", ico: "🌤" }; | ||||
| 34 | +} | ||||
| 35 | + | ||||
| 36 | +export default function QuartierBlock({ q }: { q: Quartier }) { | ||||
| 37 | + const d = q.demographie; | ||||
| 38 | + const stats: [string, string | null][] = d | ||||
| 39 | + ? [ | ||||
| 40 | + ["Revenu médian des ménages", fmtMoney(d.revenu_median)], | ||||
| 41 | + ["Ménages locataires", fmtPct(d.pct_locataires)], | ||||
| 42 | + ["Loyer moyen du secteur", fmtMoney(d.loyer_moyen)], | ||||
| 43 | + ["Âge médian", d.age_median != null ? `${Math.round(d.age_median)} ans` : null], | ||||
| 44 | + ["Français à la maison", fmtPct(d.pct_francais)], | ||||
| 45 | + ["Diplôme universitaire", fmtPct(d.pct_univ)], | ||||
| 46 | + ] | ||||
| 47 | + : []; | ||||
| 48 | + const statsOk = stats.filter(([, v]) => v != null) as [string, string][]; | ||||
| 49 | + const prox = q.proximite ?? {}; | ||||
| 50 | + const proxOk = PROX_LABELS.filter(([k]) => typeof prox[k] === "number"); | ||||
| 51 | + | ||||
| 52 | + if (statsOk.length === 0 && proxOk.length === 0 && !q.chaleur && !q.crime) | ||||
| 53 | + return null; | ||||
| 54 | + | ||||
| 55 | + return ( | ||||
| 56 | + <section className="quartier"> | ||||
| 57 | + <h2>Le quartier</h2> | ||||
| 58 | + <p className="q-sub"> | ||||
| 59 | + Secteur immédiat de l'immeuble (aire de diffusion du recensement, ± 500 habitants). | ||||
| 60 | + </p> | ||||
| 61 | + | ||||
| 62 | + {statsOk.length > 0 && ( | ||||
| 63 | + <div className="q-grid"> | ||||
| 64 | + {statsOk.map(([label, val]) => ( | ||||
| 65 | + <div className="q-cell" key={label}> | ||||
| 66 | + <div className="q-val">{val}</div> | ||||
| 67 | + <div className="q-label">{label}</div> | ||||
| 68 | + </div> | ||||
| 69 | + ))} | ||||
| 70 | + </div> | ||||
| 71 | + )} | ||||
| 72 | + | ||||
| 73 | + {proxOk.length > 0 && ( | ||||
| 74 | + <div className="q-prox"> | ||||
| 75 | + {proxOk.map(([k, label]) => { | ||||
| 76 | + const v = Math.max(0, Math.min(1, prox[k])); | ||||
| 77 | + return ( | ||||
| 78 | + <div className="q-bar" key={k}> | ||||
| 79 | + <span className="q-bar-label">{label}</span> | ||||
| 80 | + <span className="q-bar-track"> | ||||
| 81 | + <span className="q-bar-fill" style={{ width: `${Math.round(v * 100)}%` }} /> | ||||
| 82 | + </span> | ||||
| 83 | + <span className="q-bar-num">{Math.round(v * 100)}</span> | ||||
| 84 | + </div> | ||||
| 85 | + ); | ||||
| 86 | + })} | ||||
| 87 | + <div className="fine">Accessibilité 0–100 — mesures de proximité de Statistique Canada.</div> | ||||
| 88 | + </div> | ||||
| 89 | + )} | ||||
| 90 | + | ||||
| 91 | + <div className="q-badges"> | ||||
| 92 | + {q.chaleur && (() => { | ||||
| 93 | + const b = chaleurBadge(q.chaleur.classe, q.chaleur.ecart); | ||||
| 94 | + return <span className={b.cls}>{b.ico} {b.txt}</span>; | ||||
| 95 | + })()} | ||||
| 96 | + {q.crime?.type === "points" && ( | ||||
| 97 | + <span className="q-badge neutral"> | ||||
| 98 | + 🛡 {q.crime.douze_mois} acte{q.crime.douze_mois > 1 ? "s" : ""} criminel{q.crime.douze_mois > 1 ? "s" : ""} à | ||||
| 99 | + moins de 500 m (12 mois) | ||||
| 100 | + {q.crime.douze_mois_precedents > 0 && ( | ||||
| 101 | + q.crime.douze_mois <= q.crime.douze_mois_precedents | ||||
| 102 | + ? ` · en baisse (${q.crime.douze_mois_precedents} l'année d'avant)` | ||||
| 103 | + : ` · en hausse (${q.crime.douze_mois_precedents} l'année d'avant)` | ||||
| 104 | + )} | ||||
| 105 | + </span> | ||||
| 106 | + )} | ||||
| 107 | + {q.crime?.type === "igc" && ( | ||||
| 108 | + <span className="q-badge neutral"> | ||||
| 109 | + 🛡 Gravité de la criminalité ({q.crime.ville}, {q.crime.annee}) :{" "} | ||||
| 110 | + <b>{q.crime.indice}</b> | ||||
| 111 | + {q.crime.indice_canada != null && <> · Canada : {q.crime.indice_canada}</>} | ||||
| 112 | + </span> | ||||
| 113 | + )} | ||||
| 114 | + </div> | ||||
| 115 | + | ||||
| 116 | + <div className="fine"> | ||||
| 117 | + Sources : Statistique Canada (Recensement 2021, licence ouverte), INSPQ | ||||
| 118 | + (CC-BY 4.0){q.crime?.type === "points" ? ", Ville de Montréal (CC-BY 4.0)" : ""}. | ||||
| 119 | + Statistiques du secteur, pas de l'immeuble. | ||||
| 120 | + </div> | ||||
| 121 | + </section> | ||||
| 122 | + ); | ||||
| 123 | +} | ||||
modified
frontend/src/pages/Listing.tsx
+2 −0
@@ -6,6 +6,7 @@ | |||||
| 6 | import { useEffect, useState } from "react"; | 6 | import { useEffect, useState } from "react"; | ||
| 7 | import { Link, useParams } from "react-router-dom"; | 7 | import { Link, useParams } from "react-router-dom"; | ||
| 8 | import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; | 8 | import { Listing, fetchListing, fetchSources, fmtAvailability, fmtDist, fmtPrice, registerSourceNames, sourceName } from "../api"; | ||
| 9 | +import QuartierBlock from "../components/QuartierBlock"; | ||||
| 9 | 10 | ||||
| 10 | // Icônes et libellés des commodités de proximité (louka/poi.py) | 11 | // Icônes et libellés des commodités de proximité (louka/poi.py) | ||
| 11 | const POI_META: Record<string, { icon: string; label: string }> = { | 12 | const POI_META: Record<string, { icon: string; label: string }> = { | ||
@@ -136,6 +137,7 @@ export default function ListingPage() { | |||||
| 136 | {l.description && ( | 137 | {l.description && ( | ||
| 137 | <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p> | 138 | <p style={{ color: "var(--ink-2)", marginTop: 18 }}>{l.description}</p> | ||
| 138 | )} | 139 | )} | ||
| 140 | + {l.quartier && <QuartierBlock q={l.quartier} />} | ||||
| 139 | </div> | 141 | </div> | ||
| 140 | 142 | ||||
| 141 | <aside className="panel"> | 143 | <aside className="panel"> | ||
modified
frontend/src/styles.css
+36 −0
@@ -681,3 +681,39 @@ img { display: block; } | |||||
| 681 | border: 0; background: none; padding: 0; cursor: pointer; | 681 | border: 0; background: none; padding: 0; cursor: pointer; | ||
| 682 | color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px; | 682 | color: inherit; font: inherit; text-decoration: underline; text-underline-offset: 2px; | ||
| 683 | } | 683 | } | ||
| 684 | + | ||||
| 685 | +/* --- Section « Le quartier » (fiche) --------------------------------------- */ | ||||
| 686 | +.quartier { margin-top: 34px; } | ||||
| 687 | +.quartier h2 { font-size: 24px; letter-spacing: -0.02em; } | ||||
| 688 | +.q-sub { color: var(--ink-3); font-size: 13px; margin: 4px 0 16px; } | ||||
| 689 | +.q-grid { | ||||
| 690 | + display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; | ||||
| 691 | +} | ||||
| 692 | +@media (max-width: 640px) { .q-grid { grid-template-columns: repeat(2, 1fr); } } | ||||
| 693 | +.q-cell { | ||||
| 694 | + background: var(--surface); border: 1.5px solid var(--line-strong); | ||||
| 695 | + border-radius: var(--r-card); padding: 12px 14px; box-shadow: var(--shadow-flat); | ||||
| 696 | +} | ||||
| 697 | +.q-val { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.02em; } | ||||
| 698 | +.q-label { font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-3); | ||||
| 699 | + text-transform: uppercase; letter-spacing: 0.06em; margin-top: 3px; } | ||||
| 700 | +.q-prox { margin-top: 18px; display: flex; flex-direction: column; gap: 8px; } | ||||
| 701 | +.q-bar { display: flex; align-items: center; gap: 10px; font-size: 13px; } | ||||
| 702 | +.q-bar-label { flex: 0 0 150px; color: var(--ink-2); } | ||||
| 703 | +@media (max-width: 640px) { .q-bar-label { flex-basis: 120px; font-size: 12px; } } | ||||
| 704 | +.q-bar-track { | ||||
| 705 | + flex: 1; height: 10px; background: var(--surface-2); | ||||
| 706 | + border: 1px solid var(--line-strong); border-radius: 999px; overflow: hidden; | ||||
| 707 | +} | ||||
| 708 | +.q-bar-fill { display: block; height: 100%; background: var(--green); border-radius: 999px; } | ||||
| 709 | +.q-bar-num { flex: 0 0 30px; text-align: right; font-family: var(--font-mono); | ||||
| 710 | + font-size: 11.5px; font-weight: 700; } | ||||
| 711 | +.q-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } | ||||
| 712 | +.q-badge { | ||||
| 713 | + display: inline-flex; align-items: center; gap: 6px; | ||||
| 714 | + border: 1.5px solid var(--line-strong); border-radius: 999px; | ||||
| 715 | + padding: 7px 13px; font-size: 12.5px; background: var(--surface); | ||||
| 716 | +} | ||||
| 717 | +.q-badge.cool { background: var(--lime-soft); border-color: var(--green); color: var(--green-deep); } | ||||
| 718 | +.q-badge.hot { background: var(--amber-soft); border-color: var(--amber); color: #8a5a12; } | ||||
| 719 | +.quartier .fine { margin-top: 10px; } | ||||
modified
louka/db.py
+1 −0
@@ -113,6 +113,7 @@ _MIGRATIONS = { | |||||
| 113 | "details": "TEXT", | 113 | "details": "TEXT", | ||
| 114 | "geocode_failed": "INTEGER DEFAULT 0", | 114 | "geocode_failed": "INTEGER DEFAULT 0", | ||
| 115 | "miss_count": "INTEGER DEFAULT 0", | 115 | "miss_count": "INTEGER DEFAULT 0", | ||
| 116 | + "dauid": "TEXT", # aire de diffusion 2021 (stats de quartier) | ||||
| 116 | }, | 117 | }, | ||
| 117 | "sync_log": { | 118 | "sync_log": { | ||
| 118 | "stats": "TEXT", | 119 | "stats": "TEXT", | ||
modified
louka/ingest.py
+5 −0
@@ -64,6 +64,11 @@ def watch(interval_seconds: int = 3600) -> None: | |||||
| 64 | poi.run(limit=80) | 64 | poi.run(limit=80) | ||
| 65 | except Exception as exc: | 65 | except Exception as exc: | ||
| 66 | print(f"[lou-ka] poi: erreur non bloquante: {exc}", file=sys.stderr) | 66 | print(f"[lou-ka] poi: erreur non bloquante: {exc}", file=sys.stderr) | ||
| 67 | + try: # aire de diffusion (stats de quartier) des nouvelles annonces | ||||
| 68 | + from . import quartier | ||||
| 69 | + quartier.enrich() | ||||
| 70 | + except Exception as exc: | ||||
| 71 | + print(f"[lou-ka] quartier: erreur non bloquante: {exc}", file=sys.stderr) | ||||
| 67 | print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s") | 72 | print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s") | ||
| 68 | time.sleep(interval_seconds) | 73 | time.sleep(interval_seconds) | ||
| 69 | 74 | ||||
added
louka/quartier.py
+221 −0
@@ -0,0 +1,221 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# 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 recensement | ||||
| 7 | +# - 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 immeuble | ||||
| 10 | +# - 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 | +# ----------------------------------------------------------------------------- | ||||
| 14 | +from __future__ import annotations | ||||
| 15 | + | ||||
| 16 | +import json | ||||
| 17 | +import math | ||||
| 18 | +import sqlite3 | ||||
| 19 | +import time | ||||
| 20 | +from pathlib import Path | ||||
| 21 | + | ||||
| 22 | +from . import db | ||||
| 23 | + | ||||
| 24 | +QUARTIER_DB = Path(__file__).resolve().parent.parent / "data" / "quartier.db" | ||||
| 25 | + | ||||
| 26 | +# 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"} | ||||
| 32 | + | ||||
| 33 | +# correspondance ville -> service de police du tableau IGC (35-10-0187) | ||||
| 34 | +_IGC_SERVICE = { | ||||
| 35 | + "quebec": "quebec", "levis": "levis", "montreal": "montreal", | ||||
| 36 | + "laval": "laval", "longueuil": "longueuil", | ||||
| 37 | +} | ||||
| 38 | + | ||||
| 39 | + | ||||
| 40 | +def disponible() -> bool: | ||||
| 41 | + return QUARTIER_DB.exists() | ||||
| 42 | + | ||||
| 43 | + | ||||
| 44 | +def _connect() -> sqlite3.Connection: | ||||
| 45 | + con = sqlite3.connect(f"file:{QUARTIER_DB}?mode=ro", uri=True) | ||||
| 46 | + con.row_factory = sqlite3.Row | ||||
| 47 | + return con | ||||
| 48 | + | ||||
| 49 | + | ||||
| 50 | +# --------------------------------------------------------------------------- | ||||
| 51 | +# lat/lng -> DAUID (point dans polygone, préfiltre bbox) | ||||
| 52 | +# --------------------------------------------------------------------------- | ||||
| 53 | + | ||||
| 54 | +def _dans_anneau(lat: float, lng: float, anneau: list) -> bool: | ||||
| 55 | + """Lancer de rayon (even-odd). anneau = [[lng, lat], ...].""" | ||||
| 56 | + dedans = False | ||||
| 57 | + n = len(anneau) | ||||
| 58 | + j = n - 1 | ||||
| 59 | + 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 dedans | ||||
| 65 | + j = i | ||||
| 66 | + return dedans | ||||
| 67 | + | ||||
| 68 | + | ||||
| 69 | +def 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 None | ||||
| 80 | + | ||||
| 81 | + | ||||
| 82 | +# --------------------------------------------------------------------------- | ||||
| 83 | +# Assemblage pour la fiche | ||||
| 84 | +# --------------------------------------------------------------------------- | ||||
| 85 | + | ||||
| 86 | +def _cle_ville(city: str) -> str: | ||||
| 87 | + import unicodedata | ||||
| 88 | + s = "".join(c for c in unicodedata.normalize("NFD", city or "") | ||||
| 89 | + if unicodedata.category(c) != "Mn") | ||||
| 90 | + return s.strip().lower() | ||||
| 91 | + | ||||
| 92 | + | ||||
| 93 | +def _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.0 | ||||
| 96 | + 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 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 = 0 | ||||
| 103 | + for r in rows: | ||||
| 104 | + # distance exacte (le bbox est un carré) | ||||
| 105 | + d = math.hypot((r["lat"] - lat) * 111000.0, | ||||
| 106 | + (r["lng"] - lng) * 111000.0 * math.cos(math.radians(lat))) | ||||
| 107 | + if d > 500: | ||||
| 108 | + continue | ||||
| 109 | + if r["ts"] >= now - 365 * 86400: | ||||
| 110 | + recent += 1 | ||||
| 111 | + else: | ||||
| 112 | + avant += 1 | ||||
| 113 | + if recent == 0 and avant == 0: | ||||
| 114 | + return None | ||||
| 115 | + return {"type": "points", "rayon_m": 500, "douze_mois": recent, | ||||
| 116 | + "douze_mois_precedents": avant} | ||||
| 117 | + | ||||
| 118 | + | ||||
| 119 | +def _crime_igc(qcon: sqlite3.Connection, city: str) -> dict | None: | ||||
| 120 | + service = _IGC_SERVICE.get(_cle_ville(city)) | ||||
| 121 | + if not service: | ||||
| 122 | + return None | ||||
| 123 | + row = qcon.execute( | ||||
| 124 | + "SELECT annee, indice FROM igc WHERE service LIKE '%' || ? || '%'" | ||||
| 125 | + " ORDER BY annee DESC LIMIT 1", (service,)).fetchone() | ||||
| 126 | + if row is None or row["indice"] is None: | ||||
| 127 | + return None | ||||
| 128 | + ref = qcon.execute( | ||||
| 129 | + "SELECT indice FROM igc WHERE service LIKE '%canada%' AND annee=?", | ||||
| 130 | + (row["annee"],)).fetchone() | ||||
| 131 | + return {"type": "igc", "ville": city, "annee": row["annee"], | ||||
| 132 | + "indice": round(row["indice"], 1), | ||||
| 133 | + "indice_canada": round(ref["indice"], 1) if ref and ref["indice"] else None} | ||||
| 134 | + | ||||
| 135 | + | ||||
| 136 | +def fiche_quartier(lat: float | None, lng: float | None, city: str, | ||||
| 137 | + dauid: str | None = None) -> dict | None: | ||||
| 138 | + """Bloc « Le quartier » d'une fiche. None si données indisponibles.""" | ||||
| 139 | + if not disponible() or lat is None or lng is None: | ||||
| 140 | + return None | ||||
| 141 | + qcon = _connect() | ||||
| 142 | + try: | ||||
| 143 | + if not dauid: | ||||
| 144 | + dauid = dauid_for(qcon, lat, lng) | ||||
| 145 | + out: dict = {"dauid": dauid} | ||||
| 146 | + | ||||
| 147 | + if dauid: | ||||
| 148 | + r = qcon.execute("SELECT * FROM da_stats WHERE dauid=?", (dauid,)).fetchone() | ||||
| 149 | + if r: | ||||
| 150 | + out["demographie"] = {k: r[k] for k in | ||||
| 151 | + ("population", "densite", "age_median", | ||||
| 152 | + "revenu_median", "pct_locataires", | ||||
| 153 | + "loyer_moyen", "pct_francais", "pct_univ")} | ||||
| 154 | + r = qcon.execute("SELECT * FROM da_pmd WHERE dauid=?", (dauid,)).fetchone() | ||||
| 155 | + if r: | ||||
| 156 | + out["proximite"] = {k: r[k] for k in r.keys() if k != "dauid" | ||||
| 157 | + and r[k] is not None} | ||||
| 158 | + r = qcon.execute("SELECT quintile_materiel, quintile_social FROM da_defav" | ||||
| 159 | + " WHERE dauid=?", (dauid,)).fetchone() | ||||
| 160 | + if r: | ||||
| 161 | + out["defavorisation"] = dict(r) | ||||
| 162 | + | ||||
| 163 | + # îlot de chaleur : coordonnée exacte, sinon la plus proche (~120 m) | ||||
| 164 | + key = f"{round(lat, 4)},{round(lng, 4)}" | ||||
| 165 | + r = qcon.execute("SELECT classe, ecart FROM heat WHERE coord_key=?", | ||||
| 166 | + (key,)).fetchone() | ||||
| 167 | + if r is None: | ||||
| 168 | + r = qcon.execute( | ||||
| 169 | + "SELECT classe, ecart FROM heat WHERE coord_key LIKE ?" | ||||
| 170 | + " AND classe IS NOT NULL LIMIT 1", | ||||
| 171 | + (f"{round(lat, 3)}%",)).fetchone() | ||||
| 172 | + if r and r["classe"] is not None: | ||||
| 173 | + out["chaleur"] = {"classe": r["classe"], "ecart": r["ecart"]} | ||||
| 174 | + | ||||
| 175 | + # criminalité : points SPVM sur l'île, indice IGC ailleurs | ||||
| 176 | + crime = None | ||||
| 177 | + if _cle_ville(city) in _VILLES_SPVM: | ||||
| 178 | + crime = _crime_mtl(qcon, lat, lng) | ||||
| 179 | + if crime is None: | ||||
| 180 | + crime = _crime_igc(qcon, city) | ||||
| 181 | + if crime: | ||||
| 182 | + out["crime"] = crime | ||||
| 183 | + | ||||
| 184 | + return out if len(out) > 1 else None | ||||
| 185 | + except sqlite3.Error: | ||||
| 186 | + return None | ||||
| 187 | + finally: | ||||
| 188 | + qcon.close() | ||||
| 189 | + | ||||
| 190 | + | ||||
| 191 | +# --------------------------------------------------------------------------- | ||||
| 192 | +# Enrichissement : mémoriser le DAUID de chaque annonce (boucle watch) | ||||
| 193 | +# --------------------------------------------------------------------------- | ||||
| 194 | + | ||||
| 195 | +def enrich(limit: int | None = None) -> dict: | ||||
| 196 | + """Remplit listings.dauid pour les annonces géolocalisées qui ne l'ont pas.""" | ||||
| 197 | + if not disponible(): | ||||
| 198 | + print("[lou-ka] quartier: data/quartier.db absent — étape sautée") | ||||
| 199 | + return {"enriched": 0, "missing_db": True} | ||||
| 200 | + con = db.connect() | ||||
| 201 | + qcon = _connect() | ||||
| 202 | + rows = con.execute( | ||||
| 203 | + "SELECT uid, lat, lng FROM listings WHERE active=1 AND lat IS NOT NULL" | ||||
| 204 | + " AND (dauid IS NULL OR dauid='')").fetchall() | ||||
| 205 | + if limit is not None: | ||||
| 206 | + rows = rows[:limit] | ||||
| 207 | + done = introuvable = 0 | ||||
| 208 | + for r in rows: | ||||
| 209 | + d = dauid_for(qcon, r["lat"], r["lng"]) | ||||
| 210 | + con.execute("UPDATE listings SET dauid=? WHERE uid=?", | ||||
| 211 | + (d or "hors-zone", r["uid"])) | ||||
| 212 | + if d: | ||||
| 213 | + done += 1 | ||||
| 214 | + else: | ||||
| 215 | + introuvable += 1 | ||||
| 216 | + con.commit() | ||||
| 217 | + qcon.close() | ||||
| 218 | + con.close() | ||||
| 219 | + stats = {"enriched": done, "hors_zone": introuvable, "candidats": len(rows)} | ||||
| 220 | + print(f"[lou-ka] quartier {stats}") | ||||
| 221 | + return stats | ||||
modified
louka/web.py
+6 −0
@@ -182,6 +182,12 @@ def get_listing(uid: str): | |||||
| 182 | d["poi"] = json.loads(poi_row["pois"]) if poi_row else [] | 182 | d["poi"] = json.loads(poi_row["pois"]) if poi_row else [] | ||
| 183 | else: | 183 | else: | ||
| 184 | d["poi"] = [] | 184 | d["poi"] = [] | ||
| 185 | + # statistiques de quartier (recensement, proximité, chaleur, criminalité) | ||||
| 186 | + from . import quartier | ||||
| 187 | + dauid = d.get("dauid") | ||||
| 188 | + d["quartier"] = quartier.fiche_quartier( | ||||
| 189 | + d.get("lat"), d.get("lng"), d.get("city") or "", | ||||
| 190 | + dauid if dauid and dauid != "hors-zone" else None) | ||||
| 185 | con.close() | 191 | con.close() | ||
| 186 | if d is None: | 192 | if d is None: | ||
| 187 | raise HTTPException(404, "Annonce introuvable") | 193 | raise HTTPException(404, "Annonce introuvable") | ||
modified
run.py
+4 −0
@@ -45,6 +45,10 @@ def main() -> None: | |||||
| 45 | from louka import poi | 45 | from louka import poi | ||
| 46 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None | 46 | limit = int(sys.argv[2]) if len(sys.argv) > 2 else None | ||
| 47 | poi.run(limit) | 47 | poi.run(limit) | ||
| 48 | + elif cmd == "quartier": | ||||
| 49 | + from louka import quartier | ||||
| 50 | + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None | ||||
| 51 | + quartier.enrich(limit) | ||||
| 48 | elif cmd == "record": | 52 | elif cmd == "record": | ||
| 49 | from louka import fixtures | 53 | from louka import fixtures | ||
| 50 | from louka.connectors import CONNECTORS | 54 | from louka.connectors import CONNECTORS | ||
added
scripts/merge_quartier.py
+76 −0
@@ -0,0 +1,76 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# scripts/merge_quartier.py : fusionne les bases de préparation | ||||
| 5 | +# data/staging-{recensement,contexte,environnement}.db -> data/quartier.db | ||||
| 6 | +# (la base statique servie en production ; voir louka/quartier.py) | ||||
| 7 | +# Usage : .venv/bin/python scripts/merge_quartier.py | ||||
| 8 | +# ----------------------------------------------------------------------------- | ||||
| 9 | +from __future__ import annotations | ||||
| 10 | + | ||||
| 11 | +import sqlite3 | ||||
| 12 | +import sys | ||||
| 13 | +from pathlib import Path | ||||
| 14 | + | ||||
| 15 | +DATA = Path(__file__).resolve().parent.parent / "data" | ||||
| 16 | +CIBLE = DATA / "quartier.db" | ||||
| 17 | + | ||||
| 18 | +# staging -> tables attendues | ||||
| 19 | +SOURCES = { | ||||
| 20 | + "staging-recensement.db": ["da_poly", "da_stats"], | ||||
| 21 | + "staging-contexte.db": ["da_pmd", "da_defav", "ecoles"], | ||||
| 22 | + "staging-environnement.db": ["heat", "crime_mtl", "igc"], | ||||
| 23 | +} | ||||
| 24 | + | ||||
| 25 | + | ||||
| 26 | +def main() -> None: | ||||
| 27 | + if CIBLE.exists(): | ||||
| 28 | + CIBLE.unlink() | ||||
| 29 | + out = sqlite3.connect(CIBLE) | ||||
| 30 | + out.execute("CREATE TABLE meta (cle TEXT PRIMARY KEY, valeur TEXT)") | ||||
| 31 | + | ||||
| 32 | + for fichier, tables in SOURCES.items(): | ||||
| 33 | + chemin = DATA / fichier | ||||
| 34 | + if not chemin.exists(): | ||||
| 35 | + print(f"⚠ {fichier} absent — tables {tables} sautées", file=sys.stderr) | ||||
| 36 | + continue | ||||
| 37 | + out.execute("ATTACH DATABASE ? AS src", (str(chemin),)) | ||||
| 38 | + for t in tables: | ||||
| 39 | + existe = out.execute( | ||||
| 40 | + "SELECT name FROM src.sqlite_master WHERE type='table' AND name=?", | ||||
| 41 | + (t,)).fetchone() | ||||
| 42 | + if not existe: | ||||
| 43 | + print(f"⚠ table {t} absente de {fichier}", file=sys.stderr) | ||||
| 44 | + continue | ||||
| 45 | + schema = out.execute( | ||||
| 46 | + "SELECT sql FROM src.sqlite_master WHERE type='table' AND name=?", | ||||
| 47 | + (t,)).fetchone()[0] | ||||
| 48 | + out.execute(schema) | ||||
| 49 | + out.execute(f"INSERT INTO {t} SELECT * FROM src.{t}") | ||||
| 50 | + n = out.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0] | ||||
| 51 | + print(f"✓ {t}: {n} lignes (depuis {fichier})") | ||||
| 52 | + # préfixer les métadonnées de provenance | ||||
| 53 | + if out.execute("SELECT name FROM src.sqlite_master WHERE name='meta'").fetchone(): | ||||
| 54 | + for cle, val in out.execute("SELECT cle, valeur FROM src.meta"): | ||||
| 55 | + out.execute("INSERT OR REPLACE INTO meta VALUES (?,?)", | ||||
| 56 | + (f"{fichier.replace('.db', '')}:{cle}", val)) | ||||
| 57 | + out.commit() | ||||
| 58 | + out.execute("DETACH DATABASE src") | ||||
| 59 | + | ||||
| 60 | + # index utiles au runtime | ||||
| 61 | + for idx in [ | ||||
| 62 | + "CREATE INDEX IF NOT EXISTS idx_poly_bbox ON da_poly(lat_min, lat_max)", | ||||
| 63 | + "CREATE INDEX IF NOT EXISTS idx_crime_lat2 ON crime_mtl(lat)", | ||||
| 64 | + ]: | ||||
| 65 | + try: | ||||
| 66 | + out.execute(idx) | ||||
| 67 | + except sqlite3.Error as e: | ||||
| 68 | + print(f"⚠ index: {e}", file=sys.stderr) | ||||
| 69 | + out.commit() | ||||
| 70 | + out.execute("VACUUM") | ||||
| 71 | + out.close() | ||||
| 72 | + print(f"→ {CIBLE} : {CIBLE.stat().st_size / 1e6:.1f} Mo") | ||||
| 73 | + | ||||
| 74 | + | ||||
| 75 | +if __name__ == "__main__": | ||||
| 76 | + main() | ||||
| 77 | |||||