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
3 days agolast push
HTML 98.9% Python 0.6%

KA Scores — suite géographique maison (Walk/Transit/Bike/Calme/Services)

· environment.py : inventaire OSM par tuile (Overpass) — routes majeures,
  rails, aéroports, zones industrielles, bars, pistes cyclables (géométrie
  densifiée) et TOUS les POI par catégorie ; cache env_tiles ~3 mois.
· kascores.py : 5 scores 0-100 par immeuble + global pondéré, barème
  versionné (2026.08-v1), détails honnêtes par score, « Données
  insuffisantes »/« Non desservi » plutôt que des 0 trompeurs ; Transit =
  proximité des arrêts × percentile PMD StatCan ; calcul incrémental
  (7 900 immeubles en ~8 s), distribution de calibration en sortie.
· API : ks_* joints à /api/listings et /api/search (tri « ka », filtre
  kascore_min), détail complet sur la fiche, /api/kascores/stats.
· Frontend : pastille KA sur les cartes d annonces, section KA Scores de
  la fiche (5 jauges + détail dépliable + date/version), page /ka-scores
  (méthodologie publique + pondérations personnelles en curseurs), tri
  « Meilleur KA Score » dans la vue carte, filtre « KA Score minimal ».
· Boucle watch : env (4 tuiles max) + kascores incrémental à chaque sync.
· Tests : test_kascores.py (barème, honnêteté, rendement décroissant,
  cohérence spatiale sur le parc réel).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026) parent bcd2a31

20 changed files +1,535 −20

modified frontend/src/App.tsx +2 −0
@@ -23,6 +23,7 @@ import Home from "./pages/Home";
23 23 import ListingPage from "./pages/Listing";
24 24 import BotPage from "./pages/Bot";
25 25 import JusteValeurPage from "./pages/JusteValeur";
26 +import KaScoresPage from "./pages/KaScores";
26 27 import PasserellePage from "./pages/Passerelle";
27 28 import PrivacyPage from "./pages/Privacy";
28 29 import ProfilePage from "./pages/Profile";
@@ -345,6 +346,7 @@ export default function App() {
345 346 <Route path="/passerelle/:uid" element={<PasserellePage />} />
346 347 <Route path="/bot" element={<BotPage />} />
347 348 <Route path="/juste-valeur" element={<JusteValeurPage />} />
349 + <Route path="/ka-scores" element={<KaScoresPage />} />
348 350 <Route
349 351 path="*"
350 352 element={
modified frontend/src/api.ts +82 −2
@@ -108,6 +108,85 @@ export interface Listing {
108 108 fv_deviation?: number | null; // (prix - fv) / fv
109 109 fv_verdict?: "sous" | "marche" | "sur" | null;
110 110 fv_confidence?: "fort" | "moyen" | "faible" | null;
111 + // KA Scores (0-100, null = données insuffisantes / non desservi)
112 + ks_walk?: number | null;
113 + ks_transit?: number | null;
114 + ks_bike?: number | null;
115 + ks_calme?: number | null;
116 + ks_services?: number | null;
117 + ks_global?: number | null;
118 + kascores?: KaScores | null; // détail complet (fiche seulement)
119 +}
120 +
121 +// --- KA Scores — famille de scores maison Lou-Ka (louka/kascores.py) --------
122 +export interface KaScores {
123 + walk: number | null;
124 + transit: number | null;
125 + bike: number | null;
126 + calme: number | null;
127 + services: number | null;
128 + global: number | null;
129 + details: {
130 + walk?: { cats?: { cat: string; dist_m: number | null; pts: number }[]; bonus_choix?: number; raison?: string };
131 + transit?: { arret_bus_m?: number | null; station_metro_m?: number | null; pmd_percentile?: number | null; raison?: string };
132 + bike?: { km_cyclables_1km?: number; note?: string; raison?: string };
133 + calme?: { sources_bruit?: { source: string; dist_m: number | null; pen: number }[]; bonus_parc?: number; note?: string };
134 + services?: { familles?: Record<string, number>; raison?: string };
135 + labels?: Record<string, string | null>;
136 + };
137 + version: string;
138 + computed_at: number;
139 +}
140 +
141 +export interface KaScoresStats {
142 + annonces: number;
143 + avec_score: number;
144 + couverture_pct: number;
145 + version: string;
146 + moyennes: Record<string, { moyenne: number | null; n: number }>;
147 +}
148 +
149 +export const fetchKaScoresStats = () => get<KaScoresStats>("/api/kascores/stats");
150 +
151 +/** Libellé d'un KA Score (mêmes seuils que louka/kascores.py). */
152 +export function kaLabel(score: number | null | undefined): string | null {
153 + if (score == null) return null;
154 + if (score >= 85) return "Exceptionnel";
155 + if (score >= 70) return "Excellent";
156 + if (score >= 55) return "Très bon";
157 + if (score >= 40) return "Moyen";
158 + return "Faible";
159 +}
160 +
161 +/** Pondérations personnalisées du KA Score global (localStorage). */
162 +export const KA_DEFAULT_WEIGHTS: Record<string, number> = {
163 + walk: 0.30, transit: 0.20, bike: 0.15, calme: 0.20, services: 0.15,
164 +};
165 +
166 +export function kaWeights(): Record<string, number> {
167 + try {
168 + const raw = localStorage.getItem("louka_ks_poids");
169 + if (!raw) return KA_DEFAULT_WEIGHTS;
170 + const w = JSON.parse(raw) as Record<string, number>;
171 + return Object.keys(KA_DEFAULT_WEIGHTS).every((k) => typeof w[k] === "number")
172 + ? w : KA_DEFAULT_WEIGHTS;
173 + } catch {
174 + return KA_DEFAULT_WEIGHTS;
175 + }
176 +}
177 +
178 +/** KA Score global recalculé avec les pondérations de l'utilisateur. */
179 +export function kaGlobal(
180 + s: { walk?: number | null; transit?: number | null; bike?: number | null;
181 + calme?: number | null; services?: number | null },
182 + weights: Record<string, number> = kaWeights(),
183 +): number | null {
184 + let poids = 0, acquis = 0;
185 + for (const k of Object.keys(KA_DEFAULT_WEIGHTS)) {
186 + const v = (s as Record<string, number | null | undefined>)[k];
187 + if (v != null) { poids += weights[k]!; acquis += weights[k]! * v; }
188 + }
189 + return poids < 0.5 ? null : Math.round((acquis / poids) * 10) / 10;
111 190 }
112 191
113 192 export interface FairValueDetail {
@@ -192,7 +271,8 @@ export interface ListingFilters {
192 271 area_min?: string; // superficie minimale (pi²)
193 272 q?: string;
194 273 deal?: string; // "sous" | "marche" | "sur" (juste valeur)
195 − sort?: string; // "deal" = meilleures affaires d'abord
274 + kascore_min?: string; // KA Score global minimal ("60" | "70" | "80")
275 + sort?: string; // "deal" affaires | "ka" KA Score
196 276 }
197 277
198 278 export function fetchListings(f: ListingFilters, limit?: number, offset?: number) {
@@ -210,7 +290,7 @@ export function fetchListings(f: ListingFilters, limit?: number, offset?: number
210 290 /** Point compact : [uid, lng, lat, prix, verdict ("s"|"m"|"o"|null)]. */
211 291 export type SearchPoint = [string, number, number, number | null, string | null];
212 292
213 −export type SearchSort = "prix" | "prix_desc" | "recent" | "deal";
293 +export type SearchSort = "prix" | "prix_desc" | "recent" | "deal" | "ka";
214 294
215 295 export interface SearchQuery extends Omit<ListingFilters, "sort"> {
216 296 bbox?: string; // ouest,sud,est,nord — zone visible de la carte
added frontend/src/components/KaScoreBadge.tsx +65 −0
@@ -0,0 +1,65 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/KaScoreBadge.tsx : pastilles KA Score (design system Groupe-KA).
5 +// · compact : « KA 78 » sur les cartes d'annonces et mini-fiches ;
6 +// · cercle : jauge circulaire d'un sous-score sur la fiche.
7 +// -----------------------------------------------------------------------------
8 +import { kaLabel } from "../api";
9 +
10 +/** Classe de teinte par tranche (styles dans styles.css). */
11 +export function kaTint(score: number | null | undefined): string {
12 + if (score == null) return "na";
13 + if (score >= 70) return "haut";
14 + if (score >= 55) return "bon";
15 + if (score >= 40) return "moyen";
16 + return "bas";
17 +}
18 +
19 +/** Pastille compacte « KA 78 » — cartes de liste, carrousel, mini-fiches. */
20 +export default function KaScoreBadge({ score, title }: {
21 + score: number | null | undefined;
22 + title?: string;
23 +}) {
24 + if (score == null) return null;
25 + return (
26 + <span
27 + className={`ka-badge ka-${kaTint(score)}`}
28 + title={title ?? `KA Score ${Math.round(score)} — ${kaLabel(score)} · voir la méthodologie sur /ka-scores`}
29 + aria-label={`KA Score ${Math.round(score)} sur 100, ${kaLabel(score)}`}
30 + >
31 + <span className="ka-badge-logo">KA</span> {Math.round(score)}
32 + </span>
33 + );
34 +}
35 +
36 +/** Jauge circulaire d'un sous-score (fiche d'annonce, page méthodologie). */
37 +export function KaScoreCircle({ score, nom, note }: {
38 + score: number | null;
39 + nom: string;
40 + note?: string | null;
41 +}) {
42 + const r = 26;
43 + const c = 2 * Math.PI * r;
44 + const part = score == null ? 0 : Math.max(0, Math.min(1, score / 100));
45 + return (
46 + <div className={`ka-circle ka-${kaTint(score)}`} role="img"
47 + aria-label={`${nom} : ${score == null ? note ?? "données insuffisantes" : `${Math.round(score)} sur 100`}`}>
48 + <svg viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
49 + <circle cx="32" cy="32" r={r} className="ka-circle-fond" />
50 + <circle
51 + cx="32" cy="32" r={r} className="ka-circle-arc"
52 + strokeDasharray={`${c * part} ${c}`}
53 + transform="rotate(-90 32 32)"
54 + />
55 + <text x="32" y="37" textAnchor="middle" className="ka-circle-val">
56 + {score == null ? "—" : Math.round(score)}
57 + </text>
58 + </svg>
59 + <div className="ka-circle-nom">{nom}</div>
60 + <div className="ka-circle-label">
61 + {score == null ? (note ?? "Données insuffisantes") : kaLabel(score)}
62 + </div>
63 + </div>
64 + );
65 +}
added frontend/src/components/KaScoresBlock.tsx +125 −0
@@ -0,0 +1,125 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// components/KaScoresBlock.tsx : section « KA Scores » de la fiche d'annonce.
5 +// Pastille globale + 5 jauges (Walk/Transit/Bike/Calme/Services), détail
6 +// honnête par score, score personnalisé selon les pondérations de
7 +// l'utilisateur (/ka-scores), lien vers la méthodologie.
8 +// -----------------------------------------------------------------------------
9 +import { Link } from "react-router-dom";
10 +import { KaScores, KA_DEFAULT_WEIGHTS, fmtDist, kaGlobal, kaWeights } from "../api";
11 +import KaScoreBadge, { KaScoreCircle } from "./KaScoreBadge";
12 +
13 +const CAT_LABELS: Record<string, string> = {
14 + epicerie: "Épicerie", pharmacie: "Pharmacie", parc: "Parc", cafe: "Café",
15 + ecole: "École", clinique: "Clinique", garderie: "Garderie",
16 + depanneur: "Dépanneur", gym: "Gym", bibliotheque: "Bibliothèque",
17 +};
18 +const FAM_LABELS: Record<string, string> = {
19 + commerces: "commerces", sante: "santé", education: "éducation",
20 + loisirs: "loisirs",
21 +};
22 +
23 +export default function KaScoresBlock({ ks }: { ks: KaScores }) {
24 + const weights = kaWeights();
25 + const custom = JSON.stringify(weights) !== JSON.stringify(KA_DEFAULT_WEIGHTS);
26 + const perso = custom ? kaGlobal(ks, weights) : null;
27 + const d = ks.details ?? {};
28 + const walkCats = (d.walk?.cats ?? []).filter((c) => c.dist_m != null).slice(0, 5);
29 +
30 + return (
31 + <section className="f-bloc f-kascores" id="ka-scores">
32 + <h2>
33 + KA Scores
34 + {ks.global != null && <KaScoreBadge score={ks.global} />}
35 + {perso != null && (
36 + <span className="ka-perso" title="Score global recalculé selon vos priorités (réglées sur la page KA Scores)">
37 + vous : <b>{Math.round(perso)}</b>
38 + </span>
39 + )}
40 + </h2>
41 +
42 + <div className="ka-circles">
43 + <KaScoreCircle score={ks.walk} nom="Marche" />
44 + <KaScoreCircle score={ks.transit} nom="Transport"
45 + note={ks.transit == null ? "Non desservi" : undefined} />
46 + <KaScoreCircle score={ks.bike} nom="Vélo" />
47 + <KaScoreCircle score={ks.calme} nom="Calme" />
48 + <KaScoreCircle score={ks.services} nom="Services" />
49 + </div>
50 +
51 + <details className="ka-detail">
52 + <summary>Le détail des scores de ce secteur</summary>
53 + <div className="ka-detail-grille">
54 + {walkCats.length > 0 && (
55 + <div>
56 + <h4>Marche</h4>
57 + <ul>
58 + {walkCats.map((c) => (
59 + <li key={c.cat}>
60 + {CAT_LABELS[c.cat] ?? c.cat} : {fmtDist(c.dist_m as number)}
61 + {" "}{c.pts >= 99 ? "✓" : c.pts >= 50 ? "~" : "·"}
62 + </li>
63 + ))}
64 + </ul>
65 + </div>
66 + )}
67 + <div>
68 + <h4>Transport</h4>
69 + <ul>
70 + {d.transit?.arret_bus_m != null && (
71 + <li>Arrêt de bus à {fmtDist(d.transit.arret_bus_m)}</li>
72 + )}
73 + {d.transit?.station_metro_m != null && (
74 + <li>Station de métro à {fmtDist(d.transit.station_metro_m)}</li>
75 + )}
76 + {d.transit?.pmd_percentile != null && (
77 + <li>Desserte du secteur : {d.transit.pmd_percentile}ᵉ percentile canadien (StatCan)</li>
78 + )}
79 + {d.transit?.raison && <li>{d.transit.raison}</li>}
80 + </ul>
81 + <h4>Vélo</h4>
82 + <ul>
83 + {d.bike?.km_cyclables_1km != null && (
84 + <li>{d.bike.km_cyclables_1km.toLocaleString("fr-CA")} km de voies cyclables à moins de 1 km</li>
85 + )}
86 + {d.bike?.raison && <li>{d.bike.raison}</li>}
87 + </ul>
88 + </div>
89 + <div>
90 + <h4>Calme</h4>
91 + <ul>
92 + {(d.calme?.sources_bruit ?? []).length === 0 && (
93 + <li>Aucune source de bruit majeure détectée à proximité</li>
94 + )}
95 + {(d.calme?.sources_bruit ?? []).map((s) => (
96 + <li key={s.source}>
97 + {s.source}{s.dist_m != null ? ` à ${fmtDist(s.dist_m)}` : ""}
98 + </li>
99 + ))}
100 + {(d.calme?.bonus_parc ?? 0) > 0 && <li>Parc à proximité ✓</li>}
101 + </ul>
102 + {d.services?.familles && (
103 + <>
104 + <h4>Services</h4>
105 + <ul>
106 + {Object.entries(d.services.familles).map(([f, n]) => (
107 + <li key={f}>{n} {FAM_LABELS[f] ?? f} dans le secteur</li>
108 + ))}
109 + </ul>
110 + </>
111 + )}
112 + </div>
113 + </div>
114 + </details>
115 +
116 + <p className="fine">
117 + Scores 0-100 calculés depuis OpenStreetMap et les mesures de proximité
118 + de Statistique Canada — le Calme est une estimation d'environnement,
119 + pas une mesure sonore. Calculé le{" "}
120 + {new Date(ks.computed_at * 1000).toLocaleDateString("fr-CA")} (barème {ks.version}).{" "}
121 + <Link to="/ka-scores">Comment sont calculés les KA Scores ? — et régler vos priorités</Link>
122 + </p>
123 + </section>
124 + );
125 +}
modified frontend/src/components/ListingCard.tsx +2 −0
@@ -9,6 +9,7 @@ import { useAccount } from "../account";
9 9 import { IcoCamera, IcoHeart } from "./Icons";
10 10 import SmartImg from "./SmartImg";
11 11 import FairValueBadge from "./FairValueBadge";
12 +import KaScoreBadge from "./KaScoreBadge";
12 13
13 14 export default function ListingCard({ l }: { l: Listing }) {
14 15 const img = l.images && l.images.length > 0 ? l.images[0] : null;
@@ -44,6 +45,7 @@ export default function ListingCard({ l }: { l: Listing }) {
44 45 {l.sector && <span>{l.sector}</span>}
45 46 {l.sector && l.city && <span className="sep" />}
46 47 {l.city && <span>{l.city}</span>}
48 + {l.ks_global != null && <KaScoreBadge score={l.ks_global} />}
47 49 </div>
48 50 <div className="card-foot">
49 51 <span className="source-tag">{sourceName(l.source)}</span>
modified frontend/src/pages/Home.tsx +23 −4
@@ -69,12 +69,13 @@ export default function Home() {
69 69 const [furnished, setFurnished] = useState(params.get("furnished") ?? "");
70 70 const [areaMin, setAreaMin] = useState(params.get("area_min") ?? "");
71 71 const [deal, setDeal] = useState(params.get("deal") ?? ""); // juste valeur
72 + const [kaMin, setKaMin] = useState(params.get("kascore_min") ?? ""); // KA Score
72 73 // feuille de filtres mobile (bottom sheet) + panneau avancé desktop
73 74 const [sheetOpen, setSheetOpen] = useState(false);
74 75 const [advOpen, setAdvOpen] = useState(false);
75 76 const activeFilters = [q, city, sector, source, priceMin, priceMax, unitType,
76 − dispo, pets, furnished, areaMin, deal].filter(Boolean).length;
77 − const advCount = [dispo, pets, furnished, areaMin, source].filter(Boolean).length;
77 + dispo, pets, furnished, areaMin, deal, kaMin].filter(Boolean).length;
78 + const advCount = [dispo, pets, furnished, areaMin, source, kaMin].filter(Boolean).length;
78 79 // vue liste ou carte (mémorisée dans l'URL : /?view=carte)
79 80 const [view, setView] = useState<"liste" | "carte">(
80 81 params.get("view") === "carte" ? "carte" : "liste");
@@ -91,9 +92,10 @@ export default function Home() {
91 92 price_min: priceMin, price_max: priceMax,
92 93 pets, furnished, area_min: areaMin,
93 94 available_by: d?.days != null ? isoInDays(d.days) : "",
94 − deal, sort: deal === "sous" ? "deal" : "", // bonnes affaires d'abord
95 + deal, kascore_min: kaMin,
96 + sort: deal === "sous" ? "deal" : "", // bonnes affaires d'abord
95 97 };
96 − }, [qDebounced, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo, deal]);
98 + }, [qDebounced, city, sector, source, unitType, priceMin, priceMax, pets, furnished, areaMin, dispo, deal, kaMin]);
97 99
98 100 // compteur partagé de la vue carte (remonté par MapSearch : liste = carte)
99 101 const [mapTotal, setMapTotal] = useState<number | null>(null);
@@ -204,6 +206,7 @@ export default function Home() {
204 206 setQ(""); setCity(""); setSector(""); setSource("");
205 207 setPriceMin(""); setPriceMax(""); setUnitType("");
206 208 setDispo(""); setPets(""); setFurnished(""); setAreaMin(""); setDeal("");
209 + setKaMin("");
207 210 };
208 211
209 212 // pastilles « filtres actifs » — libellé + action de retrait
@@ -225,6 +228,7 @@ export default function Home() {
225 228 });
226 229 if (areaMin) pills.push({ label: `≥ ${areaMin} pi²`, clear: () => setAreaMin("") });
227 230 if (deal) pills.push({ label: "Sous le marché", clear: () => setDeal("") });
231 + if (kaMin) pills.push({ label: `KA Score ${kaMin}+`, clear: () => setKaMin("") });
228 232 if (source) pills.push({ label: sourceName(source), clear: () => setSource("") });
229 233
230 234 return (
@@ -404,6 +408,21 @@ export default function Home() {
404 408 ))}
405 409 </div>
406 410 </div>
411 + <div className="f-group">
412 + <label>KA Score minimal</label>
413 + <div className="seg" role="group">
414 + <button className={kaMin === "" ? "on" : ""} onClick={() => setKaMin("")}>
415 + Peu importe
416 + </button>
417 + {["60", "70", "80"].map((v) => (
418 + <button key={v} className={kaMin === v ? "on" : ""}
419 + onClick={() => setKaMin(v)}
420 + title="Score d'emplacement Lou-Ka (marche, transport, vélo, calme, services) — méthodologie sur /ka-scores">
421 + {v}+
422 + </button>
423 + ))}
424 + </div>
425 + </div>
407 426 <div className="f-group">
408 427 <label>Gestionnaire</label>
409 428 <select className="f-native" value={source} onChange={(e) => setSource(e.target.value)}>
added frontend/src/pages/KaScores.tsx +192 −0
@@ -0,0 +1,192 @@
1 +// -----------------------------------------------------------------------------
2 +// Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +// pages/KaScores.tsx : « Comment sont calculés les KA Scores ? »
5 +// Méthodologie publique (transparence obligatoire du barème), couverture
6 +// du parc en direct, et réglage des priorités personnelles (les curseurs
7 +// repondèrent le KA Score global affiché sur les fiches).
8 +// -----------------------------------------------------------------------------
9 +import { useEffect, useMemo, useState } from "react";
10 +import {
11 + KA_DEFAULT_WEIGHTS, KaScoresStats, fetchKaScoresStats, kaWeights,
12 +} from "../api";
13 +import { KaScoreCircle } from "../components/KaScoreBadge";
14 +
15 +const SCORES: { key: string; nom: string; mesure: string; methode: string }[] = [
16 + {
17 + key: "walk", nom: "KA Walk Score", mesure: "Tout faire à pied.",
18 + methode: "Distance de marche estimée au plus proche de chaque besoin "
19 + + "quotidien (épicerie, pharmacie, parc, café, école, clinique, garderie, "
20 + + "dépanneur, gym, bibliothèque), pondérée par son importance — une "
21 + + "épicerie pèse trois fois plus qu'un gym. Pleine note en deçà d'un "
22 + + "seuil par catégorie (ex. épicerie ≤ 400 m), décroissance linéaire "
23 + + "ensuite. Petit bonus quand le choix existe (3 épiceries ou cafés à "
24 + + "moins de 800 m).",
25 + },
26 + {
27 + key: "transit", nom: "KA Transit Score", mesure: "La desserte en transport collectif.",
28 + methode: "Deux volets : la distance de marche au plus proche arrêt de bus "
29 + + "ou station de métro (OpenStreetMap), et la qualité de desserte du "
30 + + "secteur mesurée par Statistique Canada (base des mesures de "
31 + + "proximité 2021, percentile canadien). Un territoire sans arrêt à "
32 + + "distance de marche affiche « Non desservi », pas un 0 brut.",
33 + },
34 + {
35 + key: "bike", nom: "KA Bike Score", mesure: "Le vélo au quotidien.",
36 + methode: "Kilomètres de voies cyclables (pistes et bandes OSM) à moins "
37 + + "de 1 km, plus l'accessibilité des besoins quotidiens à distance de "
38 + + "vélo. Le dénivelé n'est pas encore pris en compte (v1).",
39 + },
40 + {
41 + key: "calme", nom: "KA Calme Score", mesure: "La tranquillité estimée du secteur.",
42 + methode: "Pénalités décroissantes selon la distance aux sources de bruit "
43 + + "cartographiées : autoroutes, artères principales, voies ferrées, "
44 + + "aéroports et héliports, zones industrielles, concentration de bars. "
45 + + "Bonus pour un parc tout proche. C'est une estimation basée sur "
46 + + "l'environnement, PAS une mesure sonore.",
47 + },
48 + {
49 + key: "services", nom: "KA Services Score", mesure: "La richesse globale des services.",
50 + methode: "Comptage pondéré des points d'intérêt par famille — commerces "
51 + + "(1 km), santé (3 km), éducation (3 km), loisirs (1 km) — avec "
52 + + "rendement décroissant : passer de 0 à 3 épiceries compte beaucoup "
53 + + "plus que de 12 à 15.",
54 + },
55 +];
56 +
57 +const NOMS_COURTS: Record<string, string> = {
58 + walk: "Marche", transit: "Transport", bike: "Vélo",
59 + calme: "Calme", services: "Services",
60 +};
61 +
62 +export default function KaScoresPage() {
63 + const [stats, setStats] = useState<KaScoresStats | null>(null);
64 + const [weights, setWeights] = useState<Record<string, number>>(() => kaWeights());
65 +
66 + useEffect(() => {
67 + fetchKaScoresStats().then(setStats).catch(() => {});
68 + document.title = "KA Scores — méthodologie | Lou-Ka";
69 + }, []);
70 +
71 + const somme = useMemo(
72 + () => Object.values(weights).reduce((a, b) => a + b, 0), [weights]);
73 +
74 + const setW = (k: string, v: number) => {
75 + const next = { ...weights, [k]: v };
76 + setWeights(next);
77 + try { localStorage.setItem("louka_ks_poids", JSON.stringify(next)); } catch { /* privé */ }
78 + };
79 + const reset = () => {
80 + setWeights(KA_DEFAULT_WEIGHTS);
81 + try { localStorage.removeItem("louka_ks_poids"); } catch { /* privé */ }
82 + };
83 +
84 + return (
85 + <div className="container page-doc">
86 + <section className="hero hero-doc">
87 + <span className="kicker">Transparence</span>
88 + <h1>Comment sont calculés les <span className="hl">KA Scores</span> ?</h1>
89 + <p className="lede">
90 + Cinq scores maison de 0 à 100 évaluent l'emplacement de chaque
91 + logement : marche, transport collectif, vélo, calme et services.
92 + Voici exactement comment — méthode, sources et limites.
93 + </p>
94 + {stats && (
95 + <div className="stat-row">
96 + <span className="stat-chip">
97 + <b>{stats.couverture_pct.toLocaleString("fr-CA")} %</b> du parc scoré
98 + ({stats.avec_score.toLocaleString("fr-CA")} annonces)
99 + </span>
100 + {stats.moyennes.global?.moyenne != null && (
101 + <span className="stat-chip">
102 + score global moyen <b>{stats.moyennes.global.moyenne}</b>
103 + </span>
104 + )}
105 + <span className="stat-chip">barème <b>{stats.version}</b></span>
106 + </div>
107 + )}
108 + </section>
109 +
110 + <div className="ka-demo-circles" aria-hidden="true">
111 + <KaScoreCircle score={92} nom="Marche" />
112 + <KaScoreCircle score={74} nom="Transport" />
113 + <KaScoreCircle score={61} nom="Vélo" />
114 + <KaScoreCircle score={45} nom="Calme" />
115 + <KaScoreCircle score={83} nom="Services" />
116 + </div>
117 +
118 + {SCORES.map((s) => (
119 + <section className="f-bloc doc-bloc" key={s.key}>
120 + <h2>{s.nom}</h2>
121 + <p><b>Ce qu'il mesure :</b> {s.mesure}</p>
122 + <p>{s.methode}</p>
123 + {stats?.moyennes[s.key]?.moyenne != null && (
124 + <p className="fine">
125 + Moyenne du parc : {stats.moyennes[s.key]!.moyenne} —{" "}
126 + {stats.moyennes[s.key]!.n.toLocaleString("fr-CA")} immeubles évalués.
127 + </p>
128 + )}
129 + </section>
130 + ))}
131 +
132 + <section className="f-bloc doc-bloc" id="priorites">
133 + <h2>Vos priorités, votre score</h2>
134 + <p>
135 + Le KA Score global affiché partout est une moyenne pondérée des cinq
136 + scores. Réglez ici ce qui compte pour <em>vous</em> — les fiches
137 + afficheront votre score personnalisé à côté du score standard.
138 + Ces réglages restent sur votre appareil.
139 + </p>
140 + <div className="ka-poids">
141 + {Object.keys(KA_DEFAULT_WEIGHTS).map((k) => (
142 + <label key={k} className="ka-poids-row">
143 + <span>{NOMS_COURTS[k]}</span>
144 + <input
145 + type="range" min={0} max={50} step={5}
146 + value={Math.round((weights[k] ?? 0) * 100)}
147 + onChange={(e) => setW(k, Number(e.target.value) / 100)}
148 + aria-label={`Importance de ${NOMS_COURTS[k]}`}
149 + />
150 + <b>{Math.round(((weights[k] ?? 0) / (somme || 1)) * 100)} %</b>
151 + </label>
152 + ))}
153 + </div>
154 + <button className="btn btn-ghost" onClick={reset}>
155 + Revenir aux pondérations standard
156 + </button>
157 + </section>
158 +
159 + <section className="f-bloc doc-bloc">
160 + <h2>Sources, honnêteté et limites</h2>
161 + <ul className="doc-liste">
162 + <li>
163 + <b>Sources :</b> points d'intérêt, routes, rails et voies cyclables
164 + © contributeurs <a href="https://www.openstreetmap.org/copyright"
165 + target="_blank" rel="noreferrer">OpenStreetMap</a> (ODbL) ;
166 + desserte en transport : Base de données des mesures de proximité,
167 + Statistique Canada (2021).
168 + </li>
169 + <li>
170 + <b>Distances :</b> à vol d'oiseau multipliées par 1,3 (facteur
171 + réseau usuel) — pas un routage piéton exact.
172 + </li>
173 + <li>
174 + <b>Données insuffisantes :</b> un secteur mal cartographié (zone
175 + rurale) affiche « Données insuffisantes » plutôt qu'un score
176 + trompeur ; un territoire sans transport collectif affiche
177 + « Non desservi ».
178 + </li>
179 + <li>
180 + <b>Cohérence :</b> les scores sont calculés par immeuble ; deux
181 + logements du même immeuble partagent exactement les mêmes scores.
182 + </li>
183 + <li>
184 + <b>Versionnage :</b> le barème est versionné ; tout changement de
185 + méthode recalcule l'ensemble du parc et la date de calcul est
186 + affichée sur chaque fiche.
187 + </li>
188 + </ul>
189 + </section>
190 + </div>
191 + );
192 +}
modified frontend/src/pages/Listing.tsx +3 −0
@@ -16,6 +16,7 @@ import SmartImg from "../components/SmartImg";
16 16 import FairValueBadge from "../components/FairValueBadge";
17 17 import PriceAnalysis from "../components/PriceAnalysis";
18 18 import { IcoAlert, IcoDoc } from "../components/Icons";
19 +import KaScoresBlock from "../components/KaScoresBlock";
19 20 import { markSeen } from "../search/seen";
20 21
21 22 // Mini-carte 3D (Mapbox) — chargée paresseusement, comme la grande carte.
@@ -466,6 +467,8 @@ export default function ListingPage() {
466 467 </section>
467 468 )}
468 469
470 + {l.kascores && <KaScoresBlock ks={l.kascores} />}
471 +
469 472 <section className="f-bloc f-quartier" id="quartier">
470 473 {l.quartier ? <QuartierBlock q={l.quartier} /> : null}
471 474 </section>
modified frontend/src/search/MapSearch.tsx +1 −0
@@ -59,6 +59,7 @@ const SORT_CHOICES: { key: SearchSort; label: string }[] = [
59 59 { key: "prix_desc", label: "Prix décroissant" },
60 60 { key: "recent", label: "Plus récentes" },
61 61 { key: "deal", label: "Meilleures affaires" },
62 + { key: "ka", label: "Meilleur KA Score" },
62 63 ];
63 64
64 65 /** Points compacts → modèle canonique Ka Maps, avec écartement léger des
modified frontend/src/styles.css +68 −0
@@ -1768,3 +1768,71 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */
1768 1768 .ms .ka-locate { top: 88px; right: 10px; }
1769 1769 .fab-carte { bottom: 148px; }
1770 1770 }
1771 +
1772 +/* =============================================================================
1773 + KA Scores — pastilles, jauges circulaires, page méthodologie
1774 +============================================================================= */
1775 +.ka-badge {
1776 + display: inline-flex; align-items: center; gap: 4px;
1777 + border: 1.5px solid var(--ink); border-radius: var(--r-pill);
1778 + padding: 1px 8px 1px 3px; font-size: 11px; font-weight: 700;
1779 + color: var(--ink); background: var(--surface); line-height: 1.5;
1780 + vertical-align: middle;
1781 +}
1782 +.ka-badge-logo {
1783 + background: var(--ink); color: var(--white); border-radius: 999px;
1784 + font-size: 8.5px; font-weight: 800; padding: 1.5px 5px; letter-spacing: 0.4px;
1785 +}
1786 +.ka-badge.ka-haut { border-color: var(--green, #256d43); }
1787 +.ka-badge.ka-haut .ka-badge-logo { background: var(--green, #256d43); }
1788 +.ka-badge.ka-bon { border-color: var(--accent); }
1789 +.ka-badge.ka-bon .ka-badge-logo { background: var(--accent); }
1790 +.ka-badge.ka-moyen { opacity: 0.9; }
1791 +.ka-badge.ka-bas { opacity: 0.75; }
1792 +
1793 +.ka-circles, .ka-demo-circles {
1794 + display: flex; gap: 18px; flex-wrap: wrap; margin: 14px 0 6px;
1795 +}
1796 +.ka-circle { text-align: center; width: 92px; }
1797 +.ka-circle-fond {
1798 + fill: none; stroke: var(--line); stroke-width: 6;
1799 +}
1800 +.ka-circle-arc {
1801 + fill: none; stroke: var(--ink-3); stroke-width: 6; stroke-linecap: round;
1802 + transition: stroke-dasharray 0.6s ease;
1803 +}
1804 +.ka-circle.ka-haut .ka-circle-arc { stroke: var(--green, #256d43); }
1805 +.ka-circle.ka-bon .ka-circle-arc { stroke: var(--accent); }
1806 +.ka-circle.ka-moyen .ka-circle-arc { stroke: var(--orange, #d97a2b); }
1807 +.ka-circle.ka-bas .ka-circle-arc { stroke: #b3543f; }
1808 +.ka-circle-val { font: 700 17px var(--font-body); fill: var(--ink); }
1809 +.ka-circle-nom { font-size: 12px; font-weight: 700; color: var(--ink); margin-top: 4px; }
1810 +.ka-circle-label { font-size: 10.5px; color: var(--ink-3); }
1811 +
1812 +.f-kascores h2 { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
1813 +.ka-perso {
1814 + font-size: 12px; font-weight: 600; color: var(--ink-2);
1815 + border: 1.5px dashed var(--line-strong); border-radius: var(--r-pill);
1816 + padding: 2px 10px;
1817 +}
1818 +.ka-detail summary {
1819 + cursor: pointer; font-size: 13px; font-weight: 600; color: var(--ink-2);
1820 + padding: 6px 0;
1821 +}
1822 +.ka-detail-grille {
1823 + display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
1824 + gap: 14px; padding: 8px 0 4px;
1825 +}
1826 +.ka-detail-grille h4 { margin: 0 0 4px; font-size: 12px; color: var(--ink); }
1827 +.ka-detail-grille ul { margin: 0 0 10px; padding-left: 16px; font-size: 12.5px; color: var(--ink-2); }
1828 +.ka-detail-grille li { margin: 2px 0; }
1829 +
1830 +.ka-poids { display: grid; gap: 10px; margin: 14px 0; max-width: 460px; }
1831 +.ka-poids-row {
1832 + display: grid; grid-template-columns: 90px 1fr 48px; align-items: center;
1833 + gap: 12px; font-size: 13px; font-weight: 600; color: var(--ink);
1834 +}
1835 +.ka-poids-row input[type="range"] { accent-color: var(--accent); }
1836 +.ka-poids-row b { text-align: right; font-variant-numeric: tabular-nums; }
1837 +
1838 +.ms-car-price .ka-badge { margin-left: 2px; }
modified louka/db.py +23 −0
@@ -143,6 +143,26 @@ CREATE TABLE IF NOT EXISTS image_checks (
143 143 checked_at REAL
144 144 );
145 145
146 +CREATE TABLE IF NOT EXISTS env_tiles (
147 + tile_key TEXT PRIMARY KEY, -- « ty,tx » (tuiles 0,5° — voir poi.py/environment.py)
148 + data TEXT, -- JSON : routes/rails/aéro/industriel/bars/cyclable/POI
149 + fetched_at REAL
150 +);
151 +
152 +CREATE TABLE IF NOT EXISTS kascores (
153 + coord_key TEXT PRIMARY KEY, -- « lat,lng » arrondi à 4 décimales (immeuble)
154 + lat REAL, lng REAL,
155 + walk REAL, -- KA Walk Score 0-100 (NULL = données insuffisantes)
156 + transit REAL, -- KA Transit Score (NULL = non desservi/inconnu)
157 + bike REAL,
158 + calme REAL,
159 + services REAL,
160 + global REAL, -- moyenne pondérée par défaut (voir kascores.py)
161 + details TEXT, -- JSON : détail par score (fiche + méthodologie)
162 + version TEXT, -- version du barème (recalcul si changement)
163 + computed_at REAL
164 +);
165 +
146 166 CREATE TABLE IF NOT EXISTS source_profiles (
147 167 source_id TEXT PRIMARY KEY, -- id de la source (data/sources.json)
148 168 owner_user_id INTEGER, -- gestionnaire qui a réclamé la page
@@ -177,6 +197,7 @@ _MIGRATIONS = {
177 197 "quality_reasons": "TEXT", # JSON : raisons de la quarantaine
178 198 "images_ok": "TEXT", # JSON : galerie nettoyée (imgcheck.py)
179 199 "img_audit": "TEXT", # JSON : traçabilité du contrôle images
200 + "coord_key": "TEXT", # clé immeuble « lat,lng » 4 déc. (jointure kascores)
180 201 },
181 202 "sync_log": {
182 203 "stats": "TEXT",
@@ -212,6 +233,8 @@ def connect() -> sqlite3.Connection:
212 233 con.execute("CREATE UNIQUE INDEX IF NOT EXISTS users_ka_id ON users(ka_id)")
213 234 con.execute("CREATE INDEX IF NOT EXISTS idx_listings_pub"
214 235 " ON listings(active, published)")
236 + con.execute("CREATE INDEX IF NOT EXISTS idx_listings_coord"
237 + " ON listings(coord_key)")
215 238 con.commit()
216 239 # WAL : lectures (web) et écritures (sync, imgcheck) concurrentes sans
217 240 # verrou global ; busy_timeout évite les « database is locked » ponctuels.
added louka/environment.py +185 −0
@@ -0,0 +1,185 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# 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étrie
8 +# (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 ET
12 +# 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 +# -----------------------------------------------------------------------------
16 +from __future__ import annotations
17 +
18 +import json
19 +import math
20 +import time
21 +
22 +from . import db
23 +from .poi import (
24 + CATEGORIES, OVERPASS_URLS, PoiClient, TILE, TILE_MARGIN, _match_category,
25 + _tile_of,
26 +)
27 +
28 +REFRESH_AFTER = 90 * 86400 # l'environnement bâti bouge peu
29 +
30 +# 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 +]
38 +
39 +# Classes ponctuelles additionnelles (bruit / vie nocturne)
40 +_POINTS = [
41 + ("aeroport", '["aeroway"~"^(aerodrome|heliport)$"]'),
42 + ("industriel", '["landuse"="industrial"]'),
43 + ("bar", '["amenity"~"^(bar|nightclub|pub)$"]'),
44 +]
45 +
46 +
47 +def _tile_bbox(ty: int, tx: int) -> str:
48 + s = ty * TILE - TILE_MARGIN
49 + n = (ty + 1) * TILE + TILE_MARGIN
50 + w = tx * TILE - TILE_MARGIN
51 + e = (tx + 1) * TILE + TILE_MARGIN
52 + return f"{s:.4f},{w:.4f},{n:.4f},{e:.4f}"
53 +
54 +
55 +def _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;'
59 +
60 +
61 +def _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;'
66 +
67 +
68 +def _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 None
80 +
81 +
82 +def _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 None
90 +
91 +
92 +def fetch_tile(client: PoiClient, ty: int, tx: int) -> dict | None:
93 + """Inventaire environnemental d'une tuile.
94 +
95 + 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 None
102 + points_raw = client._post(_points_query(ty, tx))
103 + if points_raw is None:
104 + return None
105 +
106 + 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 + continue
113 + # nœuds arrondis à 5 décimales (~1 m) — suffisant pour des distances
114 + lines.setdefault(cls, []).append(
115 + [[round(g["lat"], 5), round(g["lon"], 5)] for g in geom])
116 +
117 + 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 + continue
125 + 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)
132 +
133 + return {"lines": lines, "points": points, "pois": pois}
134 +
135 +
136 +def 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 listings
140 + 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})
142 +
143 +
144 +def run(limit: int | None = None) -> dict:
145 + """Remplit/rafraîchit env_tiles pour toutes les tuiles du parc.
146 +
147 + `limit` borne le nombre de tuiles téléchargées cette fois-ci (2 requêtes
148 + 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 = 0
155 + 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 += 1
161 + continue
162 + if limit is not None and fetched >= limit:
163 + continue
164 + data = fetch_tile(client, ty, tx)
165 + if data is None:
166 + failed += 1
167 + print(f" ✗ tuile {key} : Overpass indisponible")
168 + continue
169 + 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 += 1
174 + 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}
180 +
181 +
182 +def 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 None
modified louka/ingest.py +8 −0
@@ -129,6 +129,14 @@ def watch(interval_seconds: int = 3600) -> None:
129 129 fairvalue.compute_all()
130 130 except Exception as exc:
131 131 print(f"[lou-ka] fairvalue: erreur non bloquante: {exc}", file=sys.stderr)
132 + try: # environnement OSM (nouvelles tuiles seulement) + KA Scores
133 + # incrémentaux des nouveaux immeubles — après géocodage
134 + from . import environment, kascores
135 + environment.run(limit=4)
136 + stats_ks = kascores.run()
137 + print(f"[lou-ka] kascores: {stats_ks}")
138 + except Exception as exc:
139 + print(f"[lou-ka] kascores: erreur non bloquante: {exc}", file=sys.stderr)
132 140 print(f"[lou-ka] prochaine synchronisation dans {interval_seconds}s")
133 141 time.sleep(interval_seconds)
134 142
added louka/kascores.py +452 −0
@@ -0,0 +1,452 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# kascores.py : les KA Scores — famille de scores maison 0-100 par immeuble.
5 +#
6 +# KA Walk Score marchabilité (besoins quotidiens à pied)
7 +# KA Transit Score desserte en transport collectif
8 +# KA Bike Score praticité du vélo
9 +# KA Calme Score tranquillité estimée du secteur
10 +# KA Services Score richesse des services (1 km / 3 km)
11 +# KA Score global moyenne pondérée (personnalisable côté client)
12 +#
13 +# Sources : OpenStreetMap (© contributeurs OSM, ODbL) via environment.py,
14 +# mesures de proximité StatCan (PMD 2021, quartier.db) pour le volet
15 +# fréquence/qualité du transport collectif. AUCUNE donnée inventée :
16 +# secteur sans données → NULL (« Données insuffisantes »), territoire sans
17 +# arrêt → NULL transit (« Non desservi »), et chaque score expose son
18 +# détail (details JSON) affiché sur la fiche.
19 +#
20 +# Honnêteté méthodologique (affichée sur /ka-scores) :
21 +# · distances à vol d'oiseau × 1,3 (facteur réseau usuel), pas un routage ;
22 +# · le Calme est une estimation d'environnement, pas une mesure sonore ;
23 +# · le Bike ignore le dénivelé (v1) ;
24 +# · barème versionné — tout changement recalcule l'ensemble du parc.
25 +# -----------------------------------------------------------------------------
26 +from __future__ import annotations
27 +
28 +import json
29 +import math
30 +import sqlite3
31 +import time
32 +from pathlib import Path
33 +
34 +from . import db
35 +from .environment import load_tile
36 +from .poi import TILE, _haversine_m, _tile_of
37 +
38 +VERSION = "2026.08-v1"
39 +DETOUR = 1.3 # vol d'oiseau → distance de marche estimée
40 +QUARTIER_DB = Path(__file__).resolve().parent.parent / "data" / "quartier.db"
41 +
42 +# --- barème Walk : (catégorie, poids, pleine note ≤ m, zéro au-delà de m) ---
43 +WALK_BAREME = [
44 + ("epicerie", 3.0, 400, 1600),
45 + ("pharmacie", 2.0, 400, 1600),
46 + ("parc", 2.0, 300, 1200),
47 + ("cafe", 1.5, 300, 1200),
48 + ("ecole", 1.5, 500, 1600),
49 + ("clinique", 1.5, 600, 2400),
50 + ("garderie", 1.0, 500, 1600),
51 + ("depanneur", 1.0, 250, 1000),
52 + ("gym", 1.0, 500, 2000),
53 + ("bibliotheque", 1.0, 500, 2000),
54 +]
55 +
56 +# --- barème Services : famille → (catégories, rayon m, saturation, poids) ---
57 +SERVICES_BAREME = [
58 + ("commerces", ("epicerie", "depanneur", "cafe"), 1000, 15, 0.35),
59 + ("sante", ("pharmacie", "clinique", "hopital"), 3000, 8, 0.25),
60 + ("education", ("ecole", "garderie", "bibliotheque"), 3000, 8, 0.20),
61 + ("loisirs", ("gym", "parc"), 1000, 6, 0.20),
62 +]
63 +
64 +# --- pondérations par défaut du score global (personnalisables client) ---
65 +GLOBAL_POIDS = {"walk": 0.30, "transit": 0.20, "bike": 0.15,
66 + "calme": 0.20, "services": 0.15}
67 +
68 +LABELS = [(85, "Exceptionnel"), (70, "Excellent"), (55, "Très bon"),
69 + (40, "Moyen"), (0, "Faible")]
70 +
71 +
72 +def label(score: float | None) -> str | None:
73 + if score is None:
74 + return None
75 + for seuil, lbl in LABELS:
76 + if score >= seuil:
77 + return lbl
78 + return "Faible"
79 +
80 +
81 +# ---------------------------------------------------------------------------
82 +# Index spatial en grille (cellules ~0,01° ≈ 1,1 km) — recherches locales
83 +# ---------------------------------------------------------------------------
84 +
85 +class Grid:
86 + def __init__(self, cell: float = 0.01) -> None:
87 + self.cell = cell
88 + self.cells: dict[tuple[int, int], list[tuple[float, float]]] = {}
89 +
90 + def add(self, lat: float, lng: float) -> None:
91 + key = (int(lat // self.cell), int(lng // self.cell))
92 + self.cells.setdefault(key, []).append((lat, lng))
93 +
94 + def near(self, lat: float, lng: float, radius_m: float):
95 + """Tous les points à ≤ radius_m (parcours des cellules voisines)."""
96 + r_lat = radius_m / 111000.0
97 + r_lng = radius_m / (111000.0 * max(0.2, math.cos(math.radians(lat))))
98 + span = int(max(r_lat, r_lng) // self.cell) + 1
99 + cy, cx = int(lat // self.cell), int(lng // self.cell)
100 + for dy in range(-span, span + 1):
101 + for dx in range(-span, span + 1):
102 + for plat, plng in self.cells.get((cy + dy, cx + dx), ()):
103 + if abs(plat - lat) > r_lat or abs(plng - lng) > r_lng:
104 + continue
105 + d = _haversine_m(lat, lng, plat, plng)
106 + if d <= radius_m:
107 + yield d, plat, plng
108 +
109 + def nearest(self, lat: float, lng: float, radius_m: float) -> float | None:
110 + best = None
111 + for d, _la, _ln in self.near(lat, lng, radius_m):
112 + if best is None or d < best:
113 + best = d
114 + return best
115 +
116 + def count(self, lat: float, lng: float, radius_m: float) -> int:
117 + return sum(1 for _ in self.near(lat, lng, radius_m))
118 +
119 +
120 +class TileIndex:
121 + """Grilles par classe pour une tuile d'environnement."""
122 +
123 + def __init__(self, env: dict) -> None:
124 + self.pois: dict[str, Grid] = {}
125 + for cat, pts in env.get("pois", {}).items():
126 + g = Grid()
127 + for lat, lng in pts:
128 + g.add(lat, lng)
129 + self.pois[cat] = g
130 + self.points: dict[str, Grid] = {}
131 + for cls, pts in env.get("points", {}).items():
132 + g = Grid()
133 + for lat, lng in pts:
134 + g.add(lat, lng)
135 + self.points[cls] = g
136 + # lignes : nœuds DENSIFIÉS (≤ 60 m) dans une grille — la distance au
137 + # nœud le plus proche approxime alors la distance à la voie, même
138 + # sur les longs segments droits (autoroutes rurales)
139 + self.lines: dict[str, Grid] = {}
140 + self.cyclable_seglen: dict[tuple[int, int], float] = {}
141 + for cls, ways in env.get("lines", {}).items():
142 + g = self.lines.setdefault(cls, Grid())
143 + for way in ways:
144 + for i, (lat, lng) in enumerate(way):
145 + g.add(lat, lng)
146 + if i == 0:
147 + continue
148 + plat, plng = way[i - 1]
149 + seg = _haversine_m(plat, plng, lat, lng)
150 + if cls == "cyclable":
151 + mid_lat, mid_lng = (lat + plat) / 2, (lng + plng) / 2
152 + key = (int(mid_lat // 0.01), int(mid_lng // 0.01))
153 + self.cyclable_seglen[key] = (
154 + self.cyclable_seglen.get(key, 0.0) + seg)
155 + if seg > 60:
156 + n = int(seg // 60)
157 + for j in range(1, n + 1):
158 + t = j / (n + 1)
159 + g.add(plat + (lat - plat) * t, plng + (lng - plng) * t)
160 +
161 + def cyclable_metres(self, lat: float, lng: float, radius_m: float) -> float:
162 + """Mètres de voies cyclables ~dans le rayon (somme par cellule)."""
163 + r_cells = int(radius_m / 1100) + 1
164 + cy, cx = int(lat // 0.01), int(lng // 0.01)
165 + total = 0.0
166 + for dy in range(-r_cells, r_cells + 1):
167 + for dx in range(-r_cells, r_cells + 1):
168 + total += self.cyclable_seglen.get((cy + dy, cx + dx), 0.0)
169 + return total
170 +
171 +
172 +# ---------------------------------------------------------------------------
173 +# Calcul des scores pour une coordonnée
174 +# ---------------------------------------------------------------------------
175 +
176 +def _decroissance(dist_m: float, pleine: float, zero: float) -> float:
177 + """1 en deçà de `pleine`, 0 au-delà de `zero`, linéaire entre les deux."""
178 + if dist_m <= pleine:
179 + return 1.0
180 + if dist_m >= zero:
181 + return 0.0
182 + return (zero - dist_m) / (zero - pleine)
183 +
184 +
185 +def score_walk(idx: TileIndex, lat: float, lng: float) -> tuple[float | None, dict]:
186 + total_poids = sum(p for _c, p, _f, _z in WALK_BAREME)
187 + acquis = 0.0
188 + cats = []
189 + trouvees = 0
190 + for cat, poids, pleine, zero in WALK_BAREME:
191 + g = idx.pois.get(cat)
192 + brut = g.nearest(lat, lng, zero / DETOUR + 200) if g else None
193 + if brut is None:
194 + cats.append({"cat": cat, "dist_m": None, "pts": 0.0})
195 + continue
196 + marche = brut * DETOUR
197 + part = _decroissance(marche, pleine, zero)
198 + acquis += poids * part
199 + trouvees += 1
200 + cats.append({"cat": cat, "dist_m": round(marche), "pts": round(part * 100)})
201 + if trouvees < 2:
202 + return None, {"cats": cats, "raison": "moins de 2 commodités cartographiées"}
203 + # bonus de choix : plusieurs épiceries/cafés à ≤ 800 m de marche
204 + bonus = 0.0
205 + for cat in ("epicerie", "cafe"):
206 + g = idx.pois.get(cat)
207 + if g and g.count(lat, lng, 800 / DETOUR) >= 3:
208 + bonus += 2.5
209 + score = min(100.0, 100.0 * acquis / total_poids + bonus)
210 + return round(score, 1), {"cats": cats, "bonus_choix": bonus}
211 +
212 +
213 +def score_transit(idx: TileIndex, lat: float, lng: float,
214 + pmd_pct: float | None) -> tuple[float | None, dict]:
215 + g_bus, g_metro = idx.pois.get("bus"), idx.pois.get("metro")
216 + d_bus = g_bus.nearest(lat, lng, 900) if g_bus else None
217 + d_metro = g_metro.nearest(lat, lng, 1600) if g_metro else None
218 + prox = 0.0
219 + if d_bus is not None:
220 + prox = max(prox, 100.0 * _decroissance(d_bus * DETOUR, 200, 900))
221 + if d_metro is not None:
222 + prox = max(prox, 100.0 * _decroissance(d_metro * DETOUR, 600, 1800) * 1.15)
223 + prox = min(100.0, prox)
224 + detail = {
225 + "arret_bus_m": round(d_bus * DETOUR) if d_bus is not None else None,
226 + "station_metro_m": round(d_metro * DETOUR) if d_metro is not None else None,
227 + "pmd_percentile": round(pmd_pct) if pmd_pct is not None else None,
228 + }
229 + if d_bus is None and d_metro is None:
230 + if pmd_pct is None or pmd_pct <= 1:
231 + return None, {**detail, "raison": "aucun arrêt à distance de marche"}
232 + return round(pmd_pct * 0.5, 1), detail # desserte lointaine plausible
233 + if pmd_pct is None:
234 + return round(prox * 0.85, 1), detail # proximité seule, prudente
235 + # proximité de l'arrêt × qualité de desserte du secteur (PMD StatCan)
236 + return round(0.45 * prox + 0.55 * pmd_pct, 1), detail
237 +
238 +
239 +def score_bike(idx: TileIndex, lat: float, lng: float) -> tuple[float | None, dict]:
240 + km = idx.cyclable_metres(lat, lng, 1000) / 1000.0
241 + infra = min(60.0, km * 11.0)
242 + # accessibilité des besoins quotidiens à vélo (seuils marche × 3)
243 + total_poids = sum(p for _c, p, _f, _z in WALK_BAREME)
244 + acquis = 0.0
245 + trouvees = 0
246 + for cat, poids, pleine, zero in WALK_BAREME:
247 + g = idx.pois.get(cat)
248 + brut = g.nearest(lat, lng, zero * 3 / DETOUR + 400) if g else None
249 + if brut is None:
250 + continue
251 + acquis += poids * _decroissance(brut * DETOUR, pleine * 3, zero * 3)
252 + trouvees += 1
253 + if trouvees < 2 and km == 0:
254 + return None, {"raison": "réseau cyclable et commodités non cartographiés"}
255 + access = 40.0 * acquis / total_poids
256 + return round(min(100.0, infra + access), 1), {
257 + "km_cyclables_1km": round(km, 1),
258 + "note": "dénivelé non pris en compte (v1)",
259 + }
260 +
261 +
262 +def score_calme(idx: TileIndex, lat: float, lng: float) -> tuple[float, dict]:
263 + score = 88.0
264 + sources = []
265 +
266 + def penalite(cls: str, rayon: float, poids: float, nom: str,
267 + lignes: bool = True) -> None:
268 + nonlocal score
269 + g = idx.lines.get(cls) if lignes else idx.points.get(cls)
270 + d = g.nearest(lat, lng, rayon) if g else None
271 + if d is not None:
272 + p = poids * _decroissance(d, rayon * 0.08, rayon)
273 + if p > 0.5:
274 + score -= p
275 + sources.append({"source": nom, "dist_m": round(d), "pen": round(p, 1)})
276 +
277 + penalite("autoroute", 800, 42, "autoroute")
278 + penalite("artere", 400, 22, "artère principale")
279 + penalite("rail", 500, 18, "voie ferrée")
280 + penalite("aeroport", 3000, 25, "aéroport/héliport", lignes=False)
281 + penalite("industriel", 600, 14, "zone industrielle", lignes=False)
282 +
283 + g_bar = idx.points.get("bar")
284 + n_bars = g_bar.count(lat, lng, 250) if g_bar else 0
285 + if n_bars >= 2:
286 + p = min(12.0, 4.0 * (n_bars - 1))
287 + score -= p
288 + sources.append({"source": f"{n_bars} bars/boîtes à moins de 250 m",
289 + "dist_m": None, "pen": round(p, 1)})
290 +
291 + g_parc = idx.pois.get("parc")
292 + d_parc = g_parc.nearest(lat, lng, 700) if g_parc else None
293 + bonus = 0.0
294 + if d_parc is not None:
295 + bonus = 8.0 if d_parc <= 300 else 4.0
296 + score += bonus
297 + return round(max(0.0, min(100.0, score)), 1), {
298 + "sources_bruit": sources, "bonus_parc": bonus,
299 + "note": "estimation basée sur l'environnement, pas une mesure sonore",
300 + }
301 +
302 +
303 +def score_services(idx: TileIndex, lat: float, lng: float) -> tuple[float | None, dict]:
304 + total = 0.0
305 + familles = {}
306 + n_cats = 0
307 + for fam, cats, rayon, sat, poids in SERVICES_BAREME:
308 + n = sum((idx.pois.get(c).count(lat, lng, rayon) if idx.pois.get(c) else 0)
309 + for c in cats)
310 + n_cats += 1 if n > 0 else 0
311 + part = min(1.0, math.log1p(n) / math.log1p(sat))
312 + total += poids * part
313 + familles[fam] = n
314 + if n_cats == 0:
315 + return None, {"familles": familles, "raison": "aucun service cartographié"}
316 + return round(100.0 * total, 1), {"familles": familles}
317 +
318 +
319 +def score_global(scores: dict[str, float | None]) -> float | None:
320 + poids_total = 0.0
321 + acquis = 0.0
322 + for k, p in GLOBAL_POIDS.items():
323 + if scores.get(k) is not None:
324 + poids_total += p
325 + acquis += p * scores[k] # type: ignore[operator]
326 + if poids_total < 0.5: # trop peu de composantes fiables
327 + return None
328 + return round(acquis / poids_total, 1)
329 +
330 +
331 +# ---------------------------------------------------------------------------
332 +# Recalcul du parc
333 +# ---------------------------------------------------------------------------
334 +
335 +def _pmd_transit_by_dauid() -> dict[str, float]:
336 + """Percentile PMD « transport collectif » par aire de diffusion."""
337 + if not QUARTIER_DB.exists():
338 + return {}
339 + qcon = sqlite3.connect(QUARTIER_DB)
340 + qcon.row_factory = sqlite3.Row
341 + try:
342 + return {r["dauid"]: r["prox_transport"] for r in qcon.execute(
343 + "SELECT dauid, prox_transport FROM da_pmd_pct"
344 + " WHERE prox_transport IS NOT NULL")}
345 + except sqlite3.OperationalError:
346 + return {}
347 + finally:
348 + qcon.close()
349 +
350 +
351 +def run(recompute_all: bool = False) -> dict:
352 + """Calcule les KA Scores de tous les immeubles couverts par env_tiles.
353 +
354 + Incrémental : seules les coordonnées sans score (ou d'une version de
355 + barème antérieure) sont recalculées, sauf `recompute_all`.
356 + """
357 + con = db.connect()
358 + t0 = time.time()
359 + pmd = _pmd_transit_by_dauid()
360 +
361 + rows = con.execute(
362 + """SELECT ROUND(lat,4) la, ROUND(lng,4) ln,
363 + MAX(dauid) dauid
364 + FROM listings
365 + WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL
366 + GROUP BY la, ln""").fetchall()
367 +
368 + existants = {r["coord_key"] for r in con.execute(
369 + "SELECT coord_key FROM kascores WHERE version=?", (VERSION,))}
370 +
371 + par_tuile: dict[tuple[int, int], list] = {}
372 + for r in rows:
373 + key = f"{r['la']},{r['ln']}"
374 + if not recompute_all and key in existants:
375 + continue
376 + par_tuile.setdefault(_tile_of(r["la"], r["ln"]), []).append(
377 + (key, r["la"], r["ln"], r["dauid"]))
378 +
379 + calcules = sans_tuile = 0
380 + for tile, coords in sorted(par_tuile.items()):
381 + env = load_tile(con, *tile)
382 + if env is None:
383 + sans_tuile += len(coords)
384 + continue
385 + idx = TileIndex(env)
386 + for key, lat, lng, dauid in coords:
387 + pmd_pct = pmd.get(dauid) if dauid and dauid != "hors-zone" else None
388 + walk, d_walk = score_walk(idx, lat, lng)
389 + transit, d_transit = score_transit(idx, lat, lng, pmd_pct)
390 + bike, d_bike = score_bike(idx, lat, lng)
391 + calme, d_calme = score_calme(idx, lat, lng)
392 + services, d_services = score_services(idx, lat, lng)
393 + scores = {"walk": walk, "transit": transit, "bike": bike,
394 + "calme": calme, "services": services}
395 + glob = score_global(scores)
396 + details = {
397 + "walk": d_walk, "transit": d_transit, "bike": d_bike,
398 + "calme": d_calme, "services": d_services,
399 + "labels": {k: label(v) for k, v in scores.items()},
400 + }
401 + con.execute(
402 + """INSERT OR REPLACE INTO kascores
403 + (coord_key, lat, lng, walk, transit, bike, calme, services,
404 + global, details, version, computed_at)
405 + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
406 + (key, lat, lng, walk, transit, bike, calme, services, glob,
407 + json.dumps(details, ensure_ascii=False), VERSION, time.time()))
408 + calcules += 1
409 + con.commit()
410 + print(f" tuile {tile[0]},{tile[1]} : {len(coords)} immeubles")
411 +
412 + # clé de jointure sur les annonces (nouvelles incluses)
413 + con.execute(
414 + """UPDATE listings
415 + SET coord_key = ROUND(lat,4) || ',' || ROUND(lng,4)
416 + WHERE lat IS NOT NULL AND lng IS NOT NULL
417 + AND (coord_key IS NULL
418 + OR coord_key != ROUND(lat,4) || ',' || ROUND(lng,4))""")
419 + con.commit()
420 +
421 + # calibration : distribution du score global sur le parc
422 + dist = [r["global"] for r in con.execute(
423 + "SELECT global FROM kascores WHERE global IS NOT NULL ORDER BY global")]
424 + deciles = ([round(dist[int(len(dist) * q / 10)]) for q in range(10)] +
425 + [round(dist[-1])]) if dist else []
426 + con.close()
427 + return {"immeubles_calcules": calcules, "sans_tuile_env": sans_tuile,
428 + "scores_en_base": len(dist), "deciles_global": deciles,
429 + "version": VERSION, "duree_s": round(time.time() - t0, 1)}
430 +
431 +
432 +def stats() -> dict:
433 + """Couverture et distributions — page méthodologie + Stats Lou-Ka."""
434 + con = db.connect()
435 + total = con.execute(
436 + """SELECT COUNT(*) c FROM listings
437 + WHERE active=1 AND published=1 AND dup_of IS NULL""").fetchone()["c"]
438 + scored = con.execute(
439 + """SELECT COUNT(*) c FROM listings l JOIN kascores k
440 + ON k.coord_key = l.coord_key
441 + WHERE l.active=1 AND l.published=1 AND l.dup_of IS NULL
442 + AND k.global IS NOT NULL""").fetchone()["c"]
443 + out: dict = {"annonces": total, "avec_score": scored,
444 + "couverture_pct": round(100.0 * scored / total, 1) if total else 0,
445 + "version": VERSION, "moyennes": {}}
446 + for col in ("walk", "transit", "bike", "calme", "services", "global"):
447 + r = con.execute(
448 + f"SELECT AVG({col}) m, COUNT({col}) n FROM kascores").fetchone()
449 + out["moyennes"][col] = {"moyenne": round(r["m"], 1) if r["m"] else None,
450 + "n": r["n"]}
451 + con.close()
452 + return out
modified louka/web.py +59 −14
@@ -87,7 +87,8 @@ def list_listings(
87 87 area_min: float | None = None, # superficie minimale (pi²)
88 88 q: str | None = None,
89 89 deal: str | None = None, # sous | marche | sur (fair value)
90 − sort: str | None = None, # "deal" = écart au marché croissant
90 + kascore_min: float | None = None, # KA Score global minimal
91 + sort: str | None = None, # "deal" affaires | "ka" KA Score
91 92 active: int = 1,
92 93 limit: int = Query(500, le=2000),
93 94 offset: int = 0,
@@ -95,8 +96,8 @@ def list_listings(
95 96 con = db.connect()
96 97 sql = ("SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"
97 98 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"
98 − " fv.confidence AS fv_confidence"
99 − " FROM listings LEFT JOIN fairvalue fv USING (uid)"
99 + " fv.confidence AS fv_confidence," + _KS_COLS +
100 + " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +
100 101 " WHERE dup_of IS NULL") # doublons masqués
101 102 args: list = []
102 103 if active in (0, 1):
@@ -133,10 +134,16 @@ def list_listings(
133 134 args += [f"%{q}%"] * 3
134 135 if deal in ("sous", "marche", "sur"):
135 136 sql += " AND fv.verdict=?"; args.append(deal)
137 + if kascore_min is not None:
138 + sql += " AND ks.global IS NOT NULL AND ks.global>=?"
139 + args.append(kascore_min)
136 140 total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"]
137 141 if sort == "deal": # meilleures affaires d'abord (écart le plus négatif)
138 142 sql += (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"
139 143 " price IS NULL, price ASC LIMIT ? OFFSET ?")
144 + elif sort == "ka": # meilleurs emplacements d'abord (KA Score global)
145 + sql += (" ORDER BY ks.global IS NULL, ks.global DESC,"
146 + " price IS NULL, price ASC LIMIT ? OFFSET ?")
140 147 else:
141 148 sql += " ORDER BY price IS NULL, price ASC LIMIT ? OFFSET ?"
142 149 args += [limit, offset]
@@ -266,7 +273,15 @@ _SEARCH_SORTS = {
266 273 "recent": " ORDER BY first_seen DESC",
267 274 "deal": (" ORDER BY fv.deviation IS NULL, fv.deviation ASC,"
268 275 " price IS NULL, price ASC"),
276 + "ka": (" ORDER BY ks.global IS NULL, ks.global DESC,"
277 + " price IS NULL, price ASC"),
269 278 }
279 +
280 +# colonnes KA Scores jointes aux annonces (pastilles + tri + fiche)
281 +_KS_COLS = (" ks.walk AS ks_walk, ks.transit AS ks_transit,"
282 + " ks.bike AS ks_bike, ks.calme AS ks_calme,"
283 + " ks.services AS ks_services, ks.global AS ks_global")
284 +_KS_JOIN = " LEFT JOIN kascores ks ON ks.coord_key = listings.coord_key"
270 285 _FV_CODES = {"sous": "s", "marche": "m", "sur": "o"}
271 286
272 287
@@ -310,7 +325,8 @@ def search_unified(
310 325 deal: str | None = None, # sous | marche | sur (juste valeur)
311 326 bbox: str | None = None, # ouest,sud,est,nord (zone visible carte)
312 327 poly: str | None = None, # lng,lat;… (zone dessinée)
313 − sort: str = "prix", # prix | prix_desc | recent | deal
328 + kascore_min: float | None = None, # KA Score global minimal (0-100)
329 + sort: str = "prix", # prix | prix_desc | recent | deal | ka
314 330 page: int = 1,
315 331 page_size: int = Query(20, ge=1, le=100),
316 332 include: str = "tout", # tout | liste (points inchangés côté client)
@@ -330,21 +346,23 @@ def search_unified(
330 346 ring = _parse_poly(poly) if poly else None
331 347
332 348 con = db.connect()
333 − sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)"
349 + sql = (" FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +
334 350 " WHERE dup_of IS NULL AND active=1 AND published=1"
335 − " AND lat IS NOT NULL AND lng IS NOT NULL")
351 + " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL")
336 352 args: list = []
337 353 if bbox:
338 354 try:
339 355 west, south, east, north = (float(v) for v in bbox.split(","))
340 356 except ValueError:
341 357 raise HTTPException(400, "bbox attendu : ouest,sud,est,nord")
342 − sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"
358 + sql += (" AND listings.lat BETWEEN ? AND ?"
359 + " AND listings.lng BETWEEN ? AND ?")
343 360 args += [south, north, west, east]
344 361 if ring:
345 362 # préfiltre SQL par l'emprise du polygone, appartenance exacte en aval
346 363 lngs = [p[0] for p in ring]; lats = [p[1] for p in ring]
347 − sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?"
364 + sql += (" AND listings.lat BETWEEN ? AND ?"
365 + " AND listings.lng BETWEEN ? AND ?")
348 366 args += [min(lats), max(lats), min(lngs), max(lngs)]
349 367 if city:
350 368 sql += " AND city=?"; args.append(city)
@@ -375,9 +393,13 @@ def search_unified(
375 393 args += [f"%{q}%"] * 3
376 394 if deal in ("sous", "marche", "sur"):
377 395 sql += " AND fv.verdict=?"; args.append(deal)
396 + if kascore_min is not None:
397 + sql += " AND ks.global IS NOT NULL AND ks.global>=?"
398 + args.append(kascore_min)
378 399
379 400 rows = con.execute(
380 − "SELECT uid, lng, lat, price, fv.verdict AS v" + sql + _SEARCH_SORTS[sort],
401 + "SELECT uid, listings.lng AS lng, listings.lat AS lat, price,"
402 + " fv.verdict AS v" + sql + _SEARCH_SORTS[sort],
381 403 args).fetchall()
382 404 if ring:
383 405 rows = [r for r in rows if _point_in_poly(r["lng"], r["lat"], ring)]
@@ -399,19 +421,21 @@ def search_unified(
399 421 by_uid = {r["uid"]: _row_to_dict(r) for r in con.execute(
400 422 "SELECT listings.*, fv.fv AS fv, fv.fv_low, fv.fv_high,"
401 423 " fv.deviation AS fv_deviation, fv.verdict AS fv_verdict,"
402 − " fv.confidence AS fv_confidence"
403 − " FROM listings LEFT JOIN fairvalue fv USING (uid)"
424 + " fv.confidence AS fv_confidence," + _KS_COLS +
425 + " FROM listings LEFT JOIN fairvalue fv USING (uid)" + _KS_JOIN +
404 426 f" WHERE uid IN ({marks})", page_uids).fetchall()}
405 427 listings = [by_uid[u] for u in page_uids if u in by_uid]
406 428
407 429 # annonces filtrées mais sans coordonnées (affichage honnête, hors carte)
408 − sql_nogeo = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL",
409 − " AND (lat IS NULL OR lng IS NULL)", 1)
430 + sql_nogeo = sql.replace(
431 + " AND listings.lat IS NOT NULL AND listings.lng IS NOT NULL",
432 + " AND (listings.lat IS NULL OR listings.lng IS NULL)", 1)
410 433 args_nogeo = list(args)
411 434 for spatial in (bbox, poly):
412 435 if spatial:
413 436 sql_nogeo = sql_nogeo.replace(
414 − " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "", 1)
437 + " AND listings.lat BETWEEN ? AND ?"
438 + " AND listings.lng BETWEEN ? AND ?", "", 1)
415 439 del args_nogeo[0:4]
416 440 unpositioned = con.execute("SELECT COUNT(*) c" + sql_nogeo,
417 441 args_nogeo).fetchone()["c"]
@@ -532,6 +556,20 @@ def get_listing(uid: str):
532 556 d["poi"] = json.loads(poi_row["pois"]) if poi_row else []
533 557 else:
534 558 d["poi"] = []
559 + # KA Scores de l'immeuble (Walk/Transit/Bike/Calme/Services + global)
560 + if d.get("coord_key"):
561 + ks = con.execute(
562 + "SELECT walk, transit, bike, calme, services, global AS glob,"
563 + " details, version, computed_at FROM kascores WHERE coord_key=?",
564 + (d["coord_key"],)).fetchone()
565 + if ks is not None:
566 + d["kascores"] = {
567 + "walk": ks["walk"], "transit": ks["transit"],
568 + "bike": ks["bike"], "calme": ks["calme"],
569 + "services": ks["services"], "global": ks["glob"],
570 + "details": json.loads(ks["details"] or "{}"),
571 + "version": ks["version"], "computed_at": ks["computed_at"],
572 + }
535 573 # statistiques de quartier (recensement, proximité, chaleur, criminalité)
536 574 from . import quartier
537 575 dauid = d.get("dauid")
@@ -549,6 +587,13 @@ def get_listing(uid: str):
549 587 return d
550 588
551 589
590 +@app.get("/api/kascores/stats")
591 +def kascores_stats():
592 + """Couverture, moyennes et version du barème — page « KA Scores »."""
593 + from . import kascores
594 + return kascores.stats()
595 +
596 +
552 597 @app.get("/api/facets")
553 598 def facets(city: str | None = None):
554 599 """Valeurs distinctes pour construire les filtres du frontend.
added reports/sync-validation/10-fiche-kascores.png +0 −0

Binary file not shown.

added reports/sync-validation/11-page-kascores.png +0 −0

Binary file not shown.

modified run.py +14 −0
@@ -11,6 +11,8 @@
11 11 python run.py record <source ...> # enregistre les fixtures de test d'une source
12 12 python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes)
13 13 python run.py poi [n] # commodités de proximité par immeuble (max n requêtes)
14 + python run.py env [n] # environnement OSM par tuile (socle des KA Scores)
15 + python run.py kascores [all] # calcule les KA Scores du parc (all = tout recalculer)
14 16 """
15 17 from __future__ import annotations
16 18
@@ -70,6 +72,18 @@ def main() -> None:
70 72 from louka import quartier
71 73 limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
72 74 quartier.enrich(limit)
75 + elif cmd == "env":
76 + # données d'environnement OSM par tuile (routes, rails, cyclable,
77 + # bars, POI complets) — cache ~3 mois, socle des KA Scores
78 + from louka import environment
79 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
80 + print(environment.run(limit))
81 + elif cmd == "kascores":
82 + # (re)calcule les KA Scores (Walk/Transit/Bike/Calme/Services + global)
83 + # incrémental ; « python run.py kascores all » force tout le parc
84 + from louka import kascores
85 + force = len(sys.argv) > 2 and sys.argv[2] == "all"
86 + print(kascores.run(recompute_all=force))
73 87 elif cmd == "record":
74 88 from louka import fixtures
75 89 from louka.connectors import CONNECTORS
added tests/run.py +106 −0
@@ -0,0 +1,106 @@
1 +#!/usr/bin/env python3
2 +# -----------------------------------------------------------------------------
3 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
4 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
5 +# run.py : point d'entrée — `sync`, `watch`, `serve`
6 +# -----------------------------------------------------------------------------
7 +"""Utilisation :
8 + python run.py sync [source ...] # synchronise les annonces
9 + python run.py watch [minutes] # synchronise en boucle (défaut 60 min)
10 + python run.py serve [port] # démarre l'API + le frontend (défaut 8080)
11 + python run.py record <source ...> # enregistre les fixtures de test d'une source
12 + python run.py geocode [n] # géocode les annonces sans coordonnées (max n requêtes)
13 + python run.py poi [n] # commodités de proximité par immeuble (max n requêtes)
14 + python run.py env [n] # environnement OSM par tuile (socle des KA Scores)
15 + python run.py kascores [all] # calcule les KA Scores du parc (all = tout recalculer)
16 +"""
17 +from __future__ import annotations
18 +
19 +import os
20 +import sys
21 +from pathlib import Path
22 +
23 +# Charger .env (FIRECRAWL_API_KEY, etc.) sans dépendance externe
24 +_env = Path(__file__).parent / ".env"
25 +if _env.exists():
26 + for line in _env.read_text().splitlines():
27 + line = line.strip()
28 + if line and not line.startswith("#") and "=" in line:
29 + k, _, v = line.partition("=")
30 + os.environ.setdefault(k.strip(), v.strip())
31 +
32 +
33 +def main() -> None:
34 + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve"
35 + if cmd == "sync":
36 + from louka import ingest
37 + ingest.run(sys.argv[2:] or None)
38 + elif cmd == "watch":
39 + from louka import ingest
40 + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 60
41 + ingest.watch(minutes * 60)
42 + elif cmd == "geocode1":
43 + from louka import geocode
44 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
45 + geocode.run(limit)
46 + elif cmd == "geocode":
47 + # lot Adresses Québec (rapide) ; « geocode1 » = ancien mode 1-par-1
48 + from louka import geocode
49 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
50 + geocode.run_batch(limit)
51 + elif cmd == "quality":
52 + # (re)calcule le score de complétude + publication/quarantaine
53 + from louka import quality
54 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
55 + quality.backfill(limit)
56 + elif cmd == "imgaudit":
57 + # contrôle qualité des images (liens morts, minuscules, placeholders,
58 + # doublons) — incrémental : python run.py imgaudit [n_annonces] [source]
59 + from louka import imgcheck
60 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else 1500
61 + src = sys.argv[3] if len(sys.argv) > 3 else None
62 + imgcheck.run(limit, source=src)
63 + elif cmd == "fairvalue":
64 + # (re)calcule la juste valeur locative de toutes les annonces publiées
65 + from louka import fairvalue
66 + fairvalue.compute_all()
67 + elif cmd == "poi":
68 + from louka import poi
69 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
70 + poi.run(limit)
71 + elif cmd == "quartier":
72 + from louka import quartier
73 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
74 + quartier.enrich(limit)
75 + elif cmd == "env":
76 + # données d'environnement OSM par tuile (routes, rails, cyclable,
77 + # bars, POI complets) — cache ~3 mois, socle des KA Scores
78 + from louka import environment
79 + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None
80 + print(environment.run(limit))
81 + elif cmd == "kascores":
82 + # (re)calcule les KA Scores (Walk/Transit/Bike/Calme/Services + global)
83 + # incrémental ; « python run.py kascores all » force tout le parc
84 + from louka import kascores
85 + force = len(sys.argv) > 2 and sys.argv[2] == "all"
86 + print(kascores.run(recompute_all=force))
87 + elif cmd == "record":
88 + from louka import fixtures
89 + from louka.connectors import CONNECTORS
90 + targets = sys.argv[2:] or sorted(CONNECTORS)
91 + for sid in targets:
92 + try:
93 + print(f"[lou-ka] record {sid} ... {fixtures.record(sid)}")
94 + except Exception as exc:
95 + print(f"[lou-ka] record {sid} ÉCHEC : {exc}", file=sys.stderr)
96 + elif cmd == "serve":
97 + import uvicorn
98 + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
99 + uvicorn.run("louka.web:app", host="0.0.0.0", port=port)
100 + else:
101 + print(__doc__)
102 + sys.exit(1)
103 +
104 +
105 +if __name__ == "__main__":
106 + main()
added tests/test_kascores.py +125 −0
@@ -0,0 +1,125 @@
1 +# -----------------------------------------------------------------------------
2 +# Lou-Ka — Agrégateur de logements à louer (province de Québec)
3 +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
4 +# test_kascores.py : barème des KA Scores — décroissance, bornes, labels,
5 +# non-aberration spatiale (deux immeubles voisins → scores proches).
6 +# -----------------------------------------------------------------------------
7 +import json
8 +import math
9 +
10 +import pytest
11 +
12 +from louka import kascores
13 +from louka.kascores import (
14 + Grid, TileIndex, _decroissance, label, score_calme, score_global,
15 + score_services, score_transit, score_walk,
16 +)
17 +
18 +
19 +def test_decroissance():
20 + assert _decroissance(100, 400, 1600) == 1.0
21 + assert _decroissance(400, 400, 1600) == 1.0
22 + assert _decroissance(1600, 400, 1600) == 0.0
23 + assert _decroissance(2500, 400, 1600) == 0.0
24 + assert 0.49 < _decroissance(1000, 400, 1600) < 0.51
25 +
26 +
27 +def test_labels():
28 + assert label(92) == "Exceptionnel"
29 + assert label(71) == "Excellent"
30 + assert label(60) == "Très bon"
31 + assert label(45) == "Moyen"
32 + assert label(10) == "Faible"
33 + assert label(None) is None
34 +
35 +
36 +def _env_urbain(lat=45.52, lng=-73.58):
37 + """Micro-quartier synthétique : tout à ~200 m."""
38 + d = 0.002 # ~200 m
39 + pois = {cat: [[lat + d, lng]] for cat, *_ in kascores.WALK_BAREME}
40 + pois["bus"] = [[lat, lng + d]]
41 + pois["metro"] = [[lat - d, lng]]
42 + pois["hopital"] = [[lat + 2 * d, lng]]
43 + return {"lines": {}, "points": {}, "pois": pois}
44 +
45 +
46 +def test_walk_urbain_vs_desert():
47 + idx = TileIndex(_env_urbain())
48 + s, det = score_walk(idx, 45.52, -73.58)
49 + assert s is not None and s > 85
50 + vide = TileIndex({"lines": {}, "points": {}, "pois": {}})
51 + s2, det2 = score_walk(vide, 45.52, -73.58)
52 + assert s2 is None and "raison" in det2 # honnêteté : pas de 0 trompeur
53 +
54 +
55 +def test_transit_non_desservi_et_blend():
56 + vide = TileIndex({"lines": {}, "points": {}, "pois": {}})
57 + s, det = score_transit(vide, 45.52, -73.58, None)
58 + assert s is None and "raison" in det # « Non desservi », pas 0
59 + idx = TileIndex(_env_urbain())
60 + proche, _ = score_transit(idx, 45.52, -73.58, 90.0)
61 + loin, _ = score_transit(idx, 45.52, -73.58, 10.0)
62 + assert proche is not None and loin is not None and proche > loin
63 +
64 +
65 +def test_calme_autoroute_penalise():
66 + lat, lng = 45.52, -73.58
67 + calme_env = {"lines": {}, "points": {}, "pois": {}}
68 + bruyant_env = {
69 + "lines": {"autoroute": [[[lat + 0.0005, lng - 0.01],
70 + [lat + 0.0005, lng + 0.01]]]},
71 + "points": {}, "pois": {},
72 + }
73 + s_calme, _ = score_calme(TileIndex(calme_env), lat, lng)
74 + s_bruyant, d = score_calme(TileIndex(bruyant_env), lat, lng)
75 + assert s_calme - s_bruyant > 25
76 + assert any("autoroute" in x["source"] for x in d["sources_bruit"])
77 +
78 +
79 +def test_services_rendement_decroissant():
80 + lat, lng = 45.52, -73.58
81 + def env(n):
82 + return {"lines": {}, "points": {}, "pois": {
83 + "epicerie": [[lat + 0.001 * i, lng] for i in range(1, n + 1)]}}
84 + s3, _ = score_services(TileIndex(env(3)), lat, lng)
85 + s6, _ = score_services(TileIndex(env(6)), lat, lng)
86 + s12, _ = score_services(TileIndex(env(12)), lat, lng)
87 + assert s3 < s6 < s12
88 + assert (s6 - s3) > (s12 - s6) # passer de 3→6 vaut plus que 6→12... x2
89 +
90 +
91 +def test_global_renormalise_sans_transit():
92 + s = score_global({"walk": 80, "transit": None, "bike": 60,
93 + "calme": 70, "services": 50})
94 + assert s is not None and 60 < s < 75
95 + assert score_global({"walk": 80, "transit": None, "bike": None,
96 + "calme": None, "services": None}) is None
97 +
98 +
99 +def test_coherence_spatiale_sur_le_parc():
100 + """Deux immeubles à < 200 m → scores globaux proches (médiane < 8 pts)."""
101 + from louka import db
102 + con = db.connect()
103 + rows = con.execute(
104 + "SELECT lat, lng, global AS g FROM kascores"
105 + " WHERE global IS NOT NULL ORDER BY coord_key LIMIT 3000").fetchall()
106 + con.close()
107 + if len(rows) < 200:
108 + pytest.skip("pas assez de scores calculés")
109 + g = Grid()
110 + vals = {}
111 + for r in rows:
112 + g.add(r["lat"], r["lng"])
113 + vals[(round(r["lat"], 5), round(r["lng"], 5))] = r["g"]
114 + diffs = []
115 + for r in rows[:800]:
116 + for d, la, ln in g.near(r["lat"], r["lng"], 200):
117 + if d < 1:
118 + continue
119 + v = vals.get((round(la, 5), round(ln, 5)))
120 + if v is not None:
121 + diffs.append(abs(v - r["g"]))
122 + assert diffs, "aucune paire voisine trouvée"
123 + diffs.sort()
124 + mediane = diffs[len(diffs) // 2]
125 + assert mediane < 8, f"médiane des écarts voisins : {mediane}"
126