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>
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 | ||