|
1 |
+# ----------------------------------------------------------------------------- |
|
2 |
+# Lou-Ka — Agrégateur de logements à louer (province de Québec) |
|
3 |
+# Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
|
4 |
+# statsextra.py : panneaux « données du territoire » de l'onglet Statistiques |
|
5 |
+# Trois blocs indépendants du marché des annonces, calculés sur nos jeux de |
|
6 |
+# données ouverts et mis en cache : |
|
7 |
+# · Registre des loyers (loyers réellement déclarés, data/rdl.db) ; |
|
8 |
+# · Population & territoire (recensement des secteurs couverts, |
|
9 |
+# quartier.db.da_stats joint aux annonces par aire de diffusion) ; |
|
10 |
+# · Prix de l'essence (stations gazquebec.ca, data/gaz.db). |
|
11 |
+# Chaque panneau réutilise les rendus existants du kit stats (kpis, donut/ |
|
12 |
+# barres, histogramme, tableau) — aucune donnée inventée : un bloc absent si |
|
13 |
+# sa source ne répond pas. |
|
14 |
+# ----------------------------------------------------------------------------- |
|
15 |
+from __future__ import annotations |
|
16 |
+ |
|
17 |
+import sqlite3 |
|
18 |
+import time |
|
19 |
+from pathlib import Path |
|
20 |
+from statistics import median |
|
21 |
+ |
|
22 |
+ROOT = Path(__file__).resolve().parent.parent |
|
23 |
+DATA = ROOT / "data" |
|
24 |
+_CACHE: dict[str, tuple[float, list]] = {} |
|
25 |
+_TTL = 1800 |
|
26 |
+ |
|
27 |
+ |
|
28 |
+def _ro(name: str) -> sqlite3.Connection | None: |
|
29 |
+ p = DATA / name |
|
30 |
+ if not p.exists(): |
|
31 |
+ return None |
|
32 |
+ con = sqlite3.connect(f"file:{p}?mode=ro", uri=True) |
|
33 |
+ con.row_factory = sqlite3.Row |
|
34 |
+ return con |
|
35 |
+ |
|
36 |
+ |
|
37 |
+def _money(v) -> str: |
|
38 |
+ return f"{round(v):,}".replace(",", " ") + " $" if v is not None else "—" |
|
39 |
+ |
|
40 |
+ |
|
41 |
+def _q(vals: list[float], f: float) -> float: |
|
42 |
+ s = sorted(vals) |
|
43 |
+ if not s: |
|
44 |
+ return 0 |
|
45 |
+ i = f * (len(s) - 1) |
|
46 |
+ lo = int(i) |
|
47 |
+ hi = min(lo + 1, len(s) - 1) |
|
48 |
+ return s[lo] + (s[hi] - s[lo]) * (i - lo) |
|
49 |
+ |
|
50 |
+ |
|
51 |
+# --- Panneau 1 : Registre des loyers ----------------------------------------- |
|
52 |
+def _panel_rdl() -> dict | None: |
|
53 |
+ con = _ro("rdl.db") |
|
54 |
+ if con is None: |
|
55 |
+ return None |
|
56 |
+ try: |
|
57 |
+ rows = con.execute( |
|
58 |
+ "SELECT price, rooms, year, city FROM rdl_housings " |
|
59 |
+ "WHERE price > 100 AND price < 20000").fetchall() |
|
60 |
+ except sqlite3.Error: |
|
61 |
+ con.close() |
|
62 |
+ return None |
|
63 |
+ con.close() |
|
64 |
+ if len(rows) < 100: |
|
65 |
+ return None |
|
66 |
+ prices = [r["price"] for r in rows] |
|
67 |
+ recent = [r["price"] for r in rows if (r["year"] or 0) >= 2023] |
|
68 |
+ villes = {r["city"] for r in rows if r["city"]} |
|
69 |
+ |
|
70 |
+ kpis = [ |
|
71 |
+ {"id": "rdl_n", "label": "Loyers déclarés", "value": len(rows)}, |
|
72 |
+ {"id": "rdl_med", "label": "Loyer médian déclaré", |
|
73 |
+ "value": round(median(prices)), "unit": "$"}, |
|
74 |
+ {"id": "rdl_rec", "label": "Médiane depuis 2023", |
|
75 |
+ "value": round(median(recent)) if recent else None, "unit": "$"}, |
|
76 |
+ {"id": "rdl_villes", "label": "Villes représentées", "value": len(villes)}, |
|
77 |
+ ] |
|
78 |
+ |
|
79 |
+ # médiane par nombre de chambres (barres) |
|
80 |
+ byr: dict[int, list[float]] = {} |
|
81 |
+ for r in rows: |
|
82 |
+ if r["rooms"] is not None and 0 <= r["rooms"] <= 6: |
|
83 |
+ byr.setdefault(r["rooms"], []).append(r["price"]) |
|
84 |
+ bars = [{"label": f"{k} ch.", "value": round(median(v))} |
|
85 |
+ for k, v in sorted(byr.items()) if len(v) >= 10] |
|
86 |
+ |
|
87 |
+ # histogramme des loyers déclarés (récents de préférence) |
|
88 |
+ base = recent if len(recent) >= 500 else prices |
|
89 |
+ lo, hi = round(_q(base, 0.02)), round(_q(base, 0.98)) |
|
90 |
+ step = max(50, round((hi - lo) / 14 / 50) * 50) |
|
91 |
+ bins = [] |
|
92 |
+ x = lo |
|
93 |
+ while x < hi: |
|
94 |
+ n = sum(1 for p in base if x <= p < x + step) |
|
95 |
+ bins.append({"label": f"{x}", "value": n}) |
|
96 |
+ x += step |
|
97 |
+ |
|
98 |
+ # top villes par nombre de déclarations |
|
99 |
+ vcount: dict[str, list[float]] = {} |
|
100 |
+ for r in rows: |
|
101 |
+ if r["city"]: |
|
102 |
+ vcount.setdefault(r["city"], []).append(r["price"]) |
|
103 |
+ top = sorted(vcount.items(), key=lambda kv: -len(kv[1]))[:15] |
|
104 |
+ table = {"id": "rdl_villes_t", |
|
105 |
+ "title": "Loyers déclarés par ville (top 15)", |
|
106 |
+ "columns": ["Ville", "Déclarations", "Loyer médian"], |
|
107 |
+ "rows": [[v, len(p), _money(median(p))] for v, p in top]} |
|
108 |
+ |
|
109 |
+ return { |
|
110 |
+ "id": "registre_loyers", |
|
111 |
+ "title": "Registre des loyers — loyers réellement payés", |
|
112 |
+ "subtitle": "Loyers déclarés volontairement par des locataires au " |
|
113 |
+ "Registre des loyers (Vivre en ville). Données citoyennes, " |
|
114 |
+ "indicatives.", |
|
115 |
+ "kpis": [k for k in kpis if k.get("value") is not None], |
|
116 |
+ "breakdowns": [{"id": "rdl_rooms", "title": "Loyer médian déclaré par " |
|
117 |
+ "nombre de chambres", "kind": "bar", "items": bars}] |
|
118 |
+ if bars else [], |
|
119 |
+ "distributions": [{"id": "rdl_hist", |
|
120 |
+ "title": "Distribution des loyers déclarés" |
|
121 |
+ + (" (depuis 2023)" if base is recent else ""), |
|
122 |
+ "unit": "$", "bins": bins}] if len(bins) >= 4 else [], |
|
123 |
+ "tables": [table], |
|
124 |
+ } |
|
125 |
+ |
|
126 |
+ |
|
127 |
+# --- Panneau 2 : Population & territoire -------------------------------------- |
|
128 |
+def _panel_population(con: sqlite3.Connection) -> dict | None: |
|
129 |
+ qc = _ro("quartier.db") |
|
130 |
+ if qc is None: |
|
131 |
+ return None |
|
132 |
+ try: |
|
133 |
+ # aires de diffusion couvertes par au moins une annonce active |
|
134 |
+ dauids = [r[0] for r in con.execute( |
|
135 |
+ "SELECT DISTINCT dauid FROM listings WHERE active=1 " |
|
136 |
+ "AND dup_of IS NULL AND dauid IS NOT NULL AND dauid<>''")] |
|
137 |
+ except sqlite3.Error: |
|
138 |
+ dauids = [] |
|
139 |
+ if len(dauids) < 20: |
|
140 |
+ qc.close() |
|
141 |
+ return None |
|
142 |
+ # charger le recensement des DA couvertes |
|
143 |
+ rows = [] |
|
144 |
+ CH = 400 |
|
145 |
+ for i in range(0, len(dauids), CH): |
|
146 |
+ chunk = dauids[i:i + CH] |
|
147 |
+ ph = ",".join("?" * len(chunk)) |
|
148 |
+ rows += qc.execute( |
|
149 |
+ f"SELECT * FROM da_stats WHERE dauid IN ({ph})", chunk).fetchall() |
|
150 |
+ qc.close() |
|
151 |
+ if not rows: |
|
152 |
+ return None |
|
153 |
+ |
|
154 |
+ def med(col): |
|
155 |
+ v = [r[col] for r in rows if r[col] is not None] |
|
156 |
+ return median(v) if v else None |
|
157 |
+ |
|
158 |
+ pop_tot = sum(r["population"] or 0 for r in rows) |
|
159 |
+ kpis = [ |
|
160 |
+ {"id": "pop_tot", "label": "Population des secteurs couverts", |
|
161 |
+ "value": round(pop_tot)}, |
|
162 |
+ {"id": "pop_rev", "label": "Revenu médian des ménages (médiane)", |
|
163 |
+ "value": round(med("revenu_median")) if med("revenu_median") else None, |
|
164 |
+ "unit": "$"}, |
|
165 |
+ {"id": "pop_loc", "label": "Part de locataires (médiane)", |
|
166 |
+ "value": round(med("pct_locataires")) if med("pct_locataires") is not None else None, |
|
167 |
+ "unit": "%"}, |
|
168 |
+ {"id": "pop_age", "label": "Âge médian (médiane des secteurs)", |
|
169 |
+ "value": round(med("age_median")) if med("age_median") else None, |
|
170 |
+ "unit": "ans"}, |
|
171 |
+ {"id": "pop_univ", "label": "Diplôme universitaire (médiane)", |
|
172 |
+ "value": round(med("pct_univ")) if med("pct_univ") is not None else None, |
|
173 |
+ "unit": "%"}, |
|
174 |
+ ] |
|
175 |
+ |
|
176 |
+ # répartition de la population par quintile de défavorisation matérielle |
|
177 |
+ defav = _ro("quartier.db") |
|
178 |
+ donut_items = [] |
|
179 |
+ try: |
|
180 |
+ dmap = {r["dauid"]: r["quintile_materiel"] for r in defav.execute( |
|
181 |
+ "SELECT dauid, quintile_materiel FROM da_defav")} |
|
182 |
+ buckets: dict[int, float] = {} |
|
183 |
+ for r in rows: |
|
184 |
+ qv = dmap.get(r["dauid"]) |
|
185 |
+ if qv: |
|
186 |
+ buckets[qv] = buckets.get(qv, 0) + (r["population"] or 0) |
|
187 |
+ labels = {1: "Très favorisé", 2: "Favorisé", 3: "Moyen", |
|
188 |
+ 4: "Défavorisé", 5: "Très défavorisé"} |
|
189 |
+ donut_items = [{"label": labels.get(k, str(k)), "value": round(v)} |
|
190 |
+ for k, v in sorted(buckets.items())] |
|
191 |
+ except sqlite3.Error: |
|
192 |
+ pass |
|
193 |
+ finally: |
|
194 |
+ if defav: |
|
195 |
+ defav.close() |
|
196 |
+ |
|
197 |
+ # tableau par ville : population, revenu médian, % locataires |
|
198 |
+ qc2 = _ro("quartier.db") |
|
199 |
+ table = None |
|
200 |
+ try: |
|
201 |
+ # villes avec le plus d'annonces actives |
|
202 |
+ top_cities = [r["city"] for r in con.execute( |
|
203 |
+ "SELECT city, COUNT(*) n FROM listings WHERE active=1 " |
|
204 |
+ "AND dup_of IS NULL AND city<>'' GROUP BY city ORDER BY n DESC " |
|
205 |
+ "LIMIT 15")] |
|
206 |
+ st = {r["dauid"]: r for r in qc2.execute("SELECT * FROM da_stats")} |
|
207 |
+ trows = [] |
|
208 |
+ for city in top_cities: |
|
209 |
+ das = [r["dauid"] for r in con.execute( |
|
210 |
+ "SELECT DISTINCT dauid FROM listings WHERE active=1 " |
|
211 |
+ "AND dup_of IS NULL AND city=? AND dauid IS NOT NULL", (city,))] |
|
212 |
+ recs = [st[d] for d in das if d in st] |
|
213 |
+ if len(recs) < 3: |
|
214 |
+ continue |
|
215 |
+ pop = sum(x["population"] or 0 for x in recs) |
|
216 |
+ rev = [x["revenu_median"] for x in recs if x["revenu_median"]] |
|
217 |
+ loc = [x["pct_locataires"] for x in recs |
|
218 |
+ if x["pct_locataires"] is not None] |
|
219 |
+ trows.append([city, f"{round(pop):,}".replace(",", " "), |
|
220 |
+ _money(median(rev)) if rev else "—", |
|
221 |
+ f"{round(median(loc))} %" if loc else "—"]) |
|
222 |
+ if trows: |
|
223 |
+ table = {"id": "pop_villes", |
|
224 |
+ "title": "Profil des secteurs couverts par ville", |
|
225 |
+ "columns": ["Ville", "Population", "Revenu médian", |
|
226 |
+ "Locataires"], |
|
227 |
+ "rows": trows} |
|
228 |
+ except sqlite3.Error: |
|
229 |
+ pass |
|
230 |
+ finally: |
|
231 |
+ qc2.close() |
|
232 |
+ |
|
233 |
+ return { |
|
234 |
+ "id": "population", |
|
235 |
+ "title": "Population & territoire", |
|
236 |
+ "subtitle": "Portrait sociodémographique des secteurs (aires de " |
|
237 |
+ "diffusion) où se trouvent les annonces — recensement.", |
|
238 |
+ "kpis": [k for k in kpis if k.get("value") is not None], |
|
239 |
+ "breakdowns": [{"id": "pop_defav", |
|
240 |
+ "title": "Population par niveau de défavorisation " |
|
241 |
+ "matérielle", "kind": "donut", "items": donut_items}] |
|
242 |
+ if donut_items else [], |
|
243 |
+ "tables": [table] if table else [], |
|
244 |
+ } |
|
245 |
+ |
|
246 |
+ |
|
247 |
+# --- Panneau 3 : Prix de l'essence ------------------------------------------- |
|
248 |
+def _panel_gaz() -> dict | None: |
|
249 |
+ try: |
|
250 |
+ from . import gaz |
|
251 |
+ gcon = gaz._connect() |
|
252 |
+ row = gcon.execute("SELECT v FROM meta WHERE k='maj'").fetchone() |
|
253 |
+ if row is None or time.time() - float(row["v"]) > gaz.TTL: |
|
254 |
+ try: |
|
255 |
+ gaz.refresh(gcon) |
|
256 |
+ except Exception: |
|
257 |
+ pass |
|
258 |
+ rows = gcon.execute( |
|
259 |
+ "SELECT region, prix_regulier, prix_super, prix_diesel " |
|
260 |
+ "FROM gaz_stations WHERE prix_regulier > 50").fetchall() |
|
261 |
+ gcon.close() |
|
262 |
+ except Exception: |
|
263 |
+ return None |
|
264 |
+ if len(rows) < 30: |
|
265 |
+ return None |
|
266 |
+ reg = [r["prix_regulier"] for r in rows] |
|
267 |
+ sup = [r["prix_super"] for r in rows if r["prix_super"]] |
|
268 |
+ die = [r["prix_diesel"] for r in rows if r["prix_diesel"]] |
|
269 |
+ |
|
270 |
+ def c(v): |
|
271 |
+ return round(v, 1) if v is not None else None |
|
272 |
+ |
|
273 |
+ kpis = [ |
|
274 |
+ {"id": "gaz_n", "label": "Stations suivies", "value": len(rows)}, |
|
275 |
+ {"id": "gaz_reg", "label": "Régulier médian", |
|
276 |
+ "value": c(median(reg)), "unit": "¢/L"}, |
|
277 |
+ {"id": "gaz_min", "label": "Meilleur prix (régulier)", |
|
278 |
+ "value": c(min(reg)), "unit": "¢/L"}, |
|
279 |
+ {"id": "gaz_sup", "label": "Super médian", |
|
280 |
+ "value": c(median(sup)) if sup else None, "unit": "¢/L"}, |
|
281 |
+ {"id": "gaz_die", "label": "Diesel médian", |
|
282 |
+ "value": c(median(die)) if die else None, "unit": "¢/L"}, |
|
283 |
+ ] |
|
284 |
+ |
|
285 |
+ # prix régulier médian par région |
|
286 |
+ byreg: dict[str, list[float]] = {} |
|
287 |
+ for r in rows: |
|
288 |
+ if r["region"]: |
|
289 |
+ byreg.setdefault(r["region"], []).append(r["prix_regulier"]) |
|
290 |
+ bars = sorted( |
|
291 |
+ ([{"label": k, "value": round(median(v), 1)} |
|
292 |
+ for k, v in byreg.items() if len(v) >= 5]), |
|
293 |
+ key=lambda x: x["value"]) |
|
294 |
+ # tableau régions |
|
295 |
+ treg = sorted(([k, len(v), round(median(v), 1)] |
|
296 |
+ for k, v in byreg.items() if len(v) >= 5), |
|
297 |
+ key=lambda x: x[2]) |
|
298 |
+ table = {"id": "gaz_regions", |
|
299 |
+ "title": "Prix de l'essence régulière par région", |
|
300 |
+ "columns": ["Région", "Stations", "Régulier médian (¢/L)"], |
|
301 |
+ "rows": [[r[0], r[1], f"{r[2]:.1f}"] for r in treg]} |
|
302 |
+ |
|
303 |
+ return { |
|
304 |
+ "id": "essence", |
|
305 |
+ "title": "Prix de l'essence au Québec", |
|
306 |
+ "subtitle": "Prix courants des stations-service (gazquebec.ca), toutes " |
|
307 |
+ "régions confondues.", |
|
308 |
+ "kpis": [k for k in kpis if k.get("value") is not None], |
|
309 |
+ "breakdowns": [{"id": "gaz_reg_bar", |
|
310 |
+ "title": "Essence régulière — médiane par région (¢/L)", |
|
311 |
+ "kind": "bar", "items": bars}] if bars else [], |
|
312 |
+ "tables": [table], |
|
313 |
+ } |
|
314 |
+ |
|
315 |
+ |
|
316 |
+def panels(con: sqlite3.Connection) -> list[dict]: |
|
317 |
+ """Retourne les panneaux « territoire » (cache 30 min). `con` = base des |
|
318 |
+ annonces (pour la jointure recensement via l'aire de diffusion).""" |
|
319 |
+ hit = _CACHE.get("panels") |
|
320 |
+ if hit and time.time() - hit[0] < _TTL: |
|
321 |
+ return hit[1] |
|
322 |
+ out = [] |
|
323 |
+ for fn in (_panel_rdl, lambda: _panel_population(con), _panel_gaz): |
|
324 |
+ try: |
|
325 |
+ p = fn() |
|
326 |
+ if p and (p.get("kpis") or p.get("tables") or p.get("breakdowns")): |
|
327 |
+ out.append(p) |
|
328 |
+ except Exception: |
|
329 |
+ continue |
|
330 |
+ _CACHE["panels"] = (time.time(), out) |
|
331 |
+ return out |
|
332 |
|