Fiche : qualité de l air (RSQAQ/PST) + essence à proximité (gazquebec.ca)
- louka/air.py : agrégats annuels par station du RSQAQ (MELCCFP) — horaires 2024-2025 (PM2.5, NO2, O3, SO2, CO) + séquentielles PST/PM10 (<LD -> LD/2), data/air.db minuscule, station la plus proche ≤ 60 km ; bloc fiche avec badge global et barres vs repères OMS 2021 / norme RAA (PST 60 µg/m³) - louka/gaz.py : copie locale des ~2 450 stations de gazquebec.ca (prix Régulier/Super/Diesel, TTL 4 h, auto-refresh au lookup) ; bloc fiche avec KPI (stations, médiane du secteur, meilleur prix) et tableau des stations les plus proches, la moins chère mise en évidence - run.py air-refresh / gaz-refresh, GET /api/air et /api/gaz Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
10 changed files +577 −0
modified
.gitignore
+2 −0
@@ -29,3 +29,5 @@ data/louka_ct.db-wal | |||||
| 29 | frontend/tsconfig.tsbuildinfo | 29 | frontend/tsconfig.tsbuildinfo | ||
| 30 | data/rdl.db | 30 | data/rdl.db | ||
| 31 | data/inondation.db | 31 | data/inondation.db | ||
| 32 | +data/air.db | ||||
| 33 | +data/gaz.db | ||||
modified
frontend/src/api.ts
+29 −0
@@ -595,3 +595,32 @@ export function placeholderImage(label?: string): string { | |||||
| 595 | `</svg>`; | 595 | `</svg>`; | ||
| 596 | return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; | 596 | return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; | ||
| 597 | } | 597 | } | ||
| 598 | + | ||||
| 599 | +export interface AirMesure { | ||||
| 600 | + moyenne: number; annee: number; n: number; unite: string; | ||||
| 601 | + ref: number | null; ref_nom: string | null; | ||||
| 602 | +} | ||||
| 603 | + | ||||
| 604 | +export interface AirNearby { | ||||
| 605 | + station: string | null; ville?: string; distance_km?: number; | ||||
| 606 | + mesures: Record<string, AirMesure>; | ||||
| 607 | +} | ||||
| 608 | + | ||||
| 609 | +/** Qualité de l'air : station RSQAQ la plus proche (MELCCFP). */ | ||||
| 610 | +export const fetchAir = (lat: number, lng: number) => | ||||
| 611 | + get<AirNearby>(`/api/air?lat=${lat}&lng=${lng}`); | ||||
| 612 | + | ||||
| 613 | +export interface GazStation { | ||||
| 614 | + nom: string; adresse: string; dist_m: number; | ||||
| 615 | + regulier: number | null; super: number | null; diesel: number | null; | ||||
| 616 | + moins_chere: boolean; | ||||
| 617 | +} | ||||
| 618 | + | ||||
| 619 | +export interface GazNearby { | ||||
| 620 | + n: number; rayon_m: number; mediane_regulier: number | null; | ||||
| 621 | + min_regulier: number | null; stations: GazStation[]; maj: string | null; | ||||
| 622 | +} | ||||
| 623 | + | ||||
| 624 | +/** Stations-service à proximité et prix courants (gazquebec.ca). */ | ||||
| 625 | +export const fetchGaz = (lat: number, lng: number) => | ||||
| 626 | + get<GazNearby>(`/api/gaz?lat=${lat}&lng=${lng}`); | ||||
added
frontend/src/components/EssenceProche.tsx
+74 −0
@@ -0,0 +1,74 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// components/EssenceProche.tsx : bloc « Essence à proximité » (fiche) | ||||
| 5 | +// Stations-service les plus proches avec les prix courants (gazquebec.ca) : | ||||
| 6 | +// médiane du secteur, station la moins chère mise en évidence, prix | ||||
| 7 | +// Régulier / Super / Diesel par station. | ||||
| 8 | +// ----------------------------------------------------------------------------- | ||||
| 9 | +import { useEffect, useState } from "react"; | ||||
| 10 | +import { fetchGaz, fmtDist, GazNearby } from "../api"; | ||||
| 11 | + | ||||
| 12 | +const cents = (v: number | null | undefined) => | ||||
| 13 | + v == null ? "—" : `${v.toLocaleString("fr-CA", { minimumFractionDigits: 1 })} ¢`; | ||||
| 14 | + | ||||
| 15 | +export default function EssenceProche({ lat, lng }: | ||||
| 16 | + { lat: number | null; lng: number | null }) { | ||||
| 17 | + const [d, setD] = useState<GazNearby | null>(null); | ||||
| 18 | + useEffect(() => { | ||||
| 19 | + setD(null); | ||||
| 20 | + if (lat == null || lng == null) return; | ||||
| 21 | + fetchGaz(lat, lng).then(setD).catch(() => setD(null)); | ||||
| 22 | + }, [lat, lng]); | ||||
| 23 | + if (lat == null || lng == null || !d || d.stations.length === 0) return null; | ||||
| 24 | + | ||||
| 25 | + return ( | ||||
| 26 | + <section className="f-bloc f-gaz" id="essence"> | ||||
| 27 | + <h2>Essence à proximité</h2> | ||||
| 28 | + <div className="rdl-kpis"> | ||||
| 29 | + <div className="rdl-kpi"> | ||||
| 30 | + <span className="rdl-kpi-v">{d.n}</span> | ||||
| 31 | + <span className="rdl-kpi-l">stations<br />à moins de {fmtDist(d.rayon_m)}</span> | ||||
| 32 | + </div> | ||||
| 33 | + {d.mediane_regulier != null && ( | ||||
| 34 | + <div className="rdl-kpi"> | ||||
| 35 | + <span className="rdl-kpi-v">{cents(d.mediane_regulier)}<small>/L</small></span> | ||||
| 36 | + <span className="rdl-kpi-l">médiane du secteur<br />essence régulière</span> | ||||
| 37 | + </div> | ||||
| 38 | + )} | ||||
| 39 | + {d.min_regulier != null && ( | ||||
| 40 | + <div className="rdl-kpi"> | ||||
| 41 | + <span className="rdl-kpi-v">{cents(d.min_regulier)}<small>/L</small></span> | ||||
| 42 | + <span className="rdl-kpi-l">meilleur prix<br />du secteur</span> | ||||
| 43 | + </div> | ||||
| 44 | + )} | ||||
| 45 | + </div> | ||||
| 46 | + <table className="rdl-table"> | ||||
| 47 | + <caption className="rdl-cap">Stations les plus proches</caption> | ||||
| 48 | + <thead className="gaz-head"> | ||||
| 49 | + <tr><th>Station</th><th>Régulier</th><th>Super</th><th>Diesel</th><th></th></tr> | ||||
| 50 | + </thead> | ||||
| 51 | + <tbody> | ||||
| 52 | + {d.stations.map((s, i) => ( | ||||
| 53 | + <tr key={i}> | ||||
| 54 | + <td className="rdl-addr"> | ||||
| 55 | + <b>{s.nom}</b> | ||||
| 56 | + {s.moins_chere && <span className="gaz-best"> la moins chère</span>} | ||||
| 57 | + <span className="gaz-adr">{s.adresse}</span> | ||||
| 58 | + </td> | ||||
| 59 | + <td className="rdl-prix">{cents(s.regulier)}</td> | ||||
| 60 | + <td className="rdl-date">{cents(s.super)}</td> | ||||
| 61 | + <td className="rdl-date">{cents(s.diesel)}</td> | ||||
| 62 | + <td className="rdl-dist">{fmtDist(s.dist_m)}</td> | ||||
| 63 | + </tr> | ||||
| 64 | + ))} | ||||
| 65 | + </tbody> | ||||
| 66 | + </table> | ||||
| 67 | + <p className="fine"> | ||||
| 68 | + Prix courants en ¢/litre —{" "} | ||||
| 69 | + <a href="https://gazquebec.ca" target="_blank" | ||||
| 70 | + rel="noopener noreferrer">gazquebec.ca</a>, mis à jour {d.maj}. | ||||
| 71 | + </p> | ||||
| 72 | + </section> | ||||
| 73 | + ); | ||||
| 74 | +} | ||||
added
frontend/src/components/QualiteAir.tsx
+86 −0
@@ -0,0 +1,86 @@ | |||||
| 1 | +// ----------------------------------------------------------------------------- | ||||
| 2 | +// Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +// components/QualiteAir.tsx : bloc « Qualité de l'air » (fiche) | ||||
| 5 | +// Moyennes annuelles de la station RSQAQ (MELCCFP) la plus proche : | ||||
| 6 | +// PM2.5, PST (particules en suspension totales), PM10, NO2, O3, SO2 — | ||||
| 7 | +// chaque mesure située par rapport à son repère annuel (OMS 2021, ou la | ||||
| 8 | +// norme québécoise RAA pour les PST) avec une barre de progression. | ||||
| 9 | +// ----------------------------------------------------------------------------- | ||||
| 10 | +import { useEffect, useState } from "react"; | ||||
| 11 | +import { AirNearby, fetchAir } from "../api"; | ||||
| 12 | + | ||||
| 13 | +const ORDRE = ["PM2.5", "PST", "PM10", "NO2", "O3", "SO2", "CO"]; | ||||
| 14 | +const NOMS: Record<string, string> = { | ||||
| 15 | + "PM2.5": "Particules fines (PM2,5)", | ||||
| 16 | + PST: "Particules totales (PST)", | ||||
| 17 | + PM10: "Particules (PM10)", | ||||
| 18 | + NO2: "Dioxyde d'azote (NO₂)", | ||||
| 19 | + O3: "Ozone (O₃)", SO2: "Dioxyde de soufre (SO₂)", CO: "Monoxyde (CO)", | ||||
| 20 | +}; | ||||
| 21 | + | ||||
| 22 | +function badge(d: AirNearby): [string, string] { | ||||
| 23 | + const pm = d.mesures?.["PM2.5"]; | ||||
| 24 | + if (pm?.ref) { | ||||
| 25 | + if (pm.moyenne <= pm.ref) return ["zi-ok", "Air de très bonne qualité"]; | ||||
| 26 | + if (pm.moyenne <= 2 * pm.ref) return ["zi-ok", "Air de bonne qualité"]; | ||||
| 27 | + if (pm.moyenne <= 3 * pm.ref) return ["zi-modere", "Qualité passable"]; | ||||
| 28 | + return ["zi-eleve", "Particules élevées"]; | ||||
| 29 | + } | ||||
| 30 | + const pst = d.mesures?.["PST"]; | ||||
| 31 | + if (pst?.ref) | ||||
| 32 | + return pst.moyenne <= pst.ref | ||||
| 33 | + ? ["zi-ok", "Particules sous la norme"] | ||||
| 34 | + : ["zi-eleve", "Particules au-dessus de la norme"]; | ||||
| 35 | + return ["zi-nc", "Mesures disponibles"]; | ||||
| 36 | +} | ||||
| 37 | + | ||||
| 38 | +export default function QualiteAir({ lat, lng }: | ||||
| 39 | + { lat: number | null; lng: number | null }) { | ||||
| 40 | + const [d, setD] = useState<AirNearby | null>(null); | ||||
| 41 | + useEffect(() => { | ||||
| 42 | + setD(null); | ||||
| 43 | + if (lat == null || lng == null) return; | ||||
| 44 | + fetchAir(lat, lng).then(setD).catch(() => setD(null)); | ||||
| 45 | + }, [lat, lng]); | ||||
| 46 | + if (lat == null || lng == null || !d || !d.station) return null; | ||||
| 47 | + | ||||
| 48 | + const pols = ORDRE.filter((p) => d.mesures[p]); | ||||
| 49 | + if (pols.length === 0) return null; | ||||
| 50 | + const [cls, label] = badge(d); | ||||
| 51 | + return ( | ||||
| 52 | + <section className="f-bloc f-air" id="qualite-air"> | ||||
| 53 | + <h2>Qualité de l'air</h2> | ||||
| 54 | + <div className="zi-head"><span className={`zi-badge ${cls}`}>{label}</span></div> | ||||
| 55 | + <ul className="air-liste"> | ||||
| 56 | + {pols.map((p) => { | ||||
| 57 | + const m = d.mesures[p]; | ||||
| 58 | + const pct = m.ref ? Math.min(150, (m.moyenne / m.ref) * 100) : null; | ||||
| 59 | + return ( | ||||
| 60 | + <li key={p}> | ||||
| 61 | + <span className="air-nom">{NOMS[p] ?? p}</span> | ||||
| 62 | + <span className="air-barre" aria-hidden="true"> | ||||
| 63 | + {pct != null && ( | ||||
| 64 | + <i className={pct > 100 ? "air-sur" : ""} | ||||
| 65 | + style={{ width: `${Math.max(4, Math.min(100, pct * 2 / 3))}%` }} /> | ||||
| 66 | + )} | ||||
| 67 | + </span> | ||||
| 68 | + <span className="air-val"> | ||||
| 69 | + {m.moyenne.toLocaleString("fr-CA")} <small>{m.unite}</small> | ||||
| 70 | + {m.ref != null && ( | ||||
| 71 | + <small className="air-ref"> · repère {m.ref_nom} : {m.ref}</small> | ||||
| 72 | + )} | ||||
| 73 | + </span> | ||||
| 74 | + </li> | ||||
| 75 | + ); | ||||
| 76 | + })} | ||||
| 77 | + </ul> | ||||
| 78 | + <p className="fine"> | ||||
| 79 | + Moyennes annuelles {Object.values(d.mesures)[0]?.annee} mesurées à la | ||||
| 80 | + station <b>{d.station}</b> ({d.ville}, à {d.distance_km} km) — Réseau de | ||||
| 81 | + surveillance de la qualité de l'air du Québec (MELCCFP, données | ||||
| 82 | + ouvertes). L'air à l'adresse peut différer localement. | ||||
| 83 | + </p> | ||||
| 84 | + </section> | ||||
| 85 | + ); | ||||
| 86 | +} | ||||
modified
frontend/src/pages/Listing.tsx
+6 −0
@@ -19,6 +19,8 @@ import FairValueBadge from "../components/FairValueBadge"; | |||||
| 19 | import PriceAnalysis from "../components/PriceAnalysis"; | 19 | import PriceAnalysis from "../components/PriceAnalysis"; | ||
| 20 | import RegistreLoyers from "../components/RegistreLoyers"; | 20 | import RegistreLoyers from "../components/RegistreLoyers"; | ||
| 21 | import RisqueInondation from "../components/RisqueInondation"; | 21 | import RisqueInondation from "../components/RisqueInondation"; | ||
| 22 | +import QualiteAir from "../components/QualiteAir"; | ||||
| 23 | +import EssenceProche from "../components/EssenceProche"; | ||||
| 22 | import { IcoAlert, IcoDoc } from "../components/Icons"; | 24 | import { IcoAlert, IcoDoc } from "../components/Icons"; | ||
| 23 | import KaScoresBlock from "../components/KaScoresBlock"; | 25 | import KaScoresBlock from "../components/KaScoresBlock"; | ||
| 24 | import { markSeen } from "../search/seen"; | 26 | import { markSeen } from "../search/seen"; | ||
@@ -477,6 +479,10 @@ export default function ListingPage() { | |||||
| 477 | 479 | ||||
| 478 | <RisqueInondation lat={l.lat} lng={l.lng} /> | 480 | <RisqueInondation lat={l.lat} lng={l.lng} /> | ||
| 479 | 481 | ||||
| 482 | + <QualiteAir lat={l.lat} lng={l.lng} /> | ||||
| 483 | + | ||||
| 484 | + <EssenceProche lat={l.lat} lng={l.lng} /> | ||||
| 485 | + | ||||
| 480 | {l.kascores && <KaScoresBlock ks={l.kascores} />} | 486 | {l.kascores && <KaScoresBlock ks={l.kascores} />} | ||
| 481 | 487 | ||||
| 482 | <section className="f-bloc f-quartier" id="quartier"> | 488 | <section className="f-bloc f-quartier" id="quartier"> | ||
modified
frontend/src/styles.css
+24 −0
@@ -1932,3 +1932,27 @@ html { scroll-padding-top: 76px; } /* header sticky au-dessus des ancres */ | |||||
| 1932 | color: var(--ink); white-space: nowrap; } | 1932 | color: var(--ink); white-space: nowrap; } | ||
| 1933 | .q-crime-n small { font-weight: 400; color: var(--ink-3); } | 1933 | .q-crime-n small { font-weight: 400; color: var(--ink-3); } | ||
| 1934 | .q-crime-src { margin-top: 8px; } | 1934 | .q-crime-src { margin-top: 8px; } | ||
| 1935 | + | ||||
| 1936 | + | ||||
| 1937 | +/* ---- Qualité de l'air (RSQAQ) + Essence à proximité ---- */ | ||||
| 1938 | +.air-liste { list-style: none; margin: 10px 0 0; padding: 0; } | ||||
| 1939 | +.air-liste li { display: flex; align-items: center; gap: 8px; | ||||
| 1940 | + padding: 4px 0; font-size: 12.5px; } | ||||
| 1941 | +.air-nom { flex: 0 0 44%; color: var(--ink-2); overflow: hidden; | ||||
| 1942 | + text-overflow: ellipsis; white-space: nowrap; } | ||||
| 1943 | +.air-barre { flex: 1 1 auto; height: 10px; background: var(--surface-2, #f0ede8); | ||||
| 1944 | + border-radius: 5px; overflow: hidden; } | ||||
| 1945 | +.air-barre i { display: block; height: 100%; border-radius: 5px; | ||||
| 1946 | + background: #4d9e64; } | ||||
| 1947 | +.air-barre i.air-sur { background: #c96a1f; } | ||||
| 1948 | +.air-val { flex: 0 0 34%; text-align: right; font-weight: 600; | ||||
| 1949 | + color: var(--ink); white-space: nowrap; overflow: hidden; | ||||
| 1950 | + text-overflow: ellipsis; } | ||||
| 1951 | +.air-val small { font-weight: 400; color: var(--ink-3); } | ||||
| 1952 | +.air-val .air-ref { display: block; font-size: 10px; } | ||||
| 1953 | +.gaz-head th { text-align: left; font-size: 11px; color: var(--ink-3); | ||||
| 1954 | + font-weight: 600; padding: 2px 8px 2px 0; } | ||||
| 1955 | +.gaz-best { display: inline-block; margin-left: 6px; padding: 1px 7px; | ||||
| 1956 | + border-radius: 999px; background: #e7f4ea; color: #1e6b34; | ||||
| 1957 | + font-size: 10.5px; font-weight: 700; } | ||||
| 1958 | +.gaz-adr { display: block; font-size: 11px; color: var(--ink-3); } | ||||
added
louka/air.py
+208 −0
@@ -0,0 +1,208 @@ | |||||
| 1 | +# ----------------------------------------------------------------------------- | ||||
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | ||||
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | ||||
| 4 | +# air.py : qualité de l'air à l'adresse — RSQAQ (MELCCFP, Données Québec) | ||||
| 5 | +# | ||||
| 6 | +# Sources ouvertes officielles du Réseau de surveillance de la qualité de | ||||
| 7 | +# l'air du Québec : | ||||
| 8 | +# · stations (rsqaq-stations) : coordonnées, ouverture/fermeture ; | ||||
| 9 | +# · données horaires continues (PM2.5, NO2, O3, SO2, CO) 2024-2025 ; | ||||
| 10 | +# · données séquentielles (Concentration PST / PM10 + métaux) 2007-2026 — | ||||
| 11 | +# les particules en suspension totales demandées (étiquette PST). | ||||
| 12 | +# `refresh()` télécharge les CSV et réduit tout en moyennes annuelles par | ||||
| 13 | +# station dans data/air.db (minuscule) ; `lookup(lat, lng)` remonte la | ||||
| 14 | +# station la plus proche (≤ 60 km) avec ses mesures les plus récentes, | ||||
| 15 | +# comparées aux repères annuels de l'OMS (2021). | ||||
| 16 | +# ----------------------------------------------------------------------------- | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | + | ||||
| 19 | +import csv | ||||
| 20 | +import io | ||||
| 21 | +import math | ||||
| 22 | +import sqlite3 | ||||
| 23 | +import time | ||||
| 24 | +import urllib.request | ||||
| 25 | +from pathlib import Path | ||||
| 26 | + | ||||
| 27 | +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "air.db" | ||||
| 28 | +UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)" | ||||
| 29 | + | ||||
| 30 | +URL_STATIONS = ("https://www.donneesquebec.ca/recherche/dataset/" | ||||
| 31 | + "8656ad05-c174-41c5-9ed7-8c69d308beb9/resource/" | ||||
| 32 | + "cebea532-a9e0-4a39-8c2d-54f33d937c73/download/" | ||||
| 33 | + "rsqaq_stations_de_la_qualite_de_lair.csv") | ||||
| 34 | +URL_HORAIRE = { | ||||
| 35 | + 2025: ("https://www.donneesquebec.ca/recherche/dataset/" | ||||
| 36 | + "a80757bd-d442-4d3d-9269-11628330b727/resource/" | ||||
| 37 | + "370a6be4-1530-4c2b-92f3-0a308224f284/download/" | ||||
| 38 | + "rsqaq_continues_horaires_2025.csv"), | ||||
| 39 | + 2024: ("https://www.donneesquebec.ca/recherche/dataset/" | ||||
| 40 | + "a80757bd-d442-4d3d-9269-11628330b727/resource/" | ||||
| 41 | + "135146b7-fd4a-4564-b10b-b1ae94257889/download/" | ||||
| 42 | + "rsqaq_continues_horaires_2024.csv"), | ||||
| 43 | +} | ||||
| 44 | +URL_SEQ = ("https://www.donneesquebec.ca/recherche/dataset/" | ||||
| 45 | + "bff56fad-22c3-450b-aafa-4ccdad6c91f2/resource/" | ||||
| 46 | + "b83cac3b-6199-4cfd-a903-cdec2369630c/download/" | ||||
| 47 | + "rsqaq_sequentielles_2007-2026.csv") | ||||
| 48 | + | ||||
| 49 | +# colonnes horaires retenues -> polluant canonique | ||||
| 50 | +_HOURLY = {"PM2.5-T640": "PM2.5", "PM2.5-BAM": "PM2.5", "NO2": "NO2", | ||||
| 51 | + "O3": "O3", "SO2": "SO2", "CO": "CO"} | ||||
| 52 | +# contaminants séquentiels retenus (concentrations de particules) | ||||
| 53 | +_SEQ = {"Concentration PST": "PST", "Concentration PM10": "PM10"} | ||||
| 54 | + | ||||
| 55 | +# repères ANNUELS (moyenne) : OMS 2021 pour PM2.5/PM10/NO2 ; PST : norme | ||||
| 56 | +# annuelle du Règlement sur l'assainissement de l'atmosphère (Québec) | ||||
| 57 | +REFS = {"PM2.5": ("OMS 2021", 5.0), "PM10": ("OMS 2021", 15.0), | ||||
| 58 | + "NO2": ("OMS 2021", 10.0), "PST": ("RAA Québec", 60.0)} | ||||
| 59 | +UNITES = {"PM2.5": "µg/m³", "PM10": "µg/m³", "PST": "µg/m³", | ||||
| 60 | + "NO2": "ppb", "O3": "ppb", "SO2": "ppb", "CO": "ppm"} | ||||
| 61 | + | ||||
| 62 | + | ||||
| 63 | +def _dl(url: str) -> io.StringIO: | ||||
| 64 | + req = urllib.request.Request(url, headers={"User-Agent": UA}) | ||||
| 65 | + with urllib.request.urlopen(req, timeout=300) as r: | ||||
| 66 | + return io.StringIO(r.read().decode("utf-8-sig")) | ||||
| 67 | + | ||||
| 68 | + | ||||
| 69 | +def refresh() -> None: | ||||
| 70 | + stations: dict[str, dict] = {} | ||||
| 71 | + for row in csv.DictReader(_dl(URL_STATIONS)): | ||||
| 72 | + sid = row["ID_STATION"].strip().lstrip("0") | ||||
| 73 | + stations[sid] = {"nom": row["NOM_STATION"], "ville": row["MUNICIPALITE"], | ||||
| 74 | + "lat": float(row["LATITUDE"] or 0), | ||||
| 75 | + "lng": float(row["LONGITUDE"] or 0)} | ||||
| 76 | + print(f"[air] {len(stations)} stations") | ||||
| 77 | + | ||||
| 78 | + # (station, annee, polluant) -> [somme, n] | ||||
| 79 | + acc: dict[tuple, list[float]] = {} | ||||
| 80 | + | ||||
| 81 | + def _sid(label: str) -> str: | ||||
| 82 | + return label.split(" - ")[0].strip().lstrip("0") | ||||
| 83 | + | ||||
| 84 | + for annee, url in URL_HORAIRE.items(): | ||||
| 85 | + n = 0 | ||||
| 86 | + rd = csv.DictReader(_dl(url)) | ||||
| 87 | + cols = [c for c in (rd.fieldnames or []) if c in _HOURLY] | ||||
| 88 | + for row in rd: | ||||
| 89 | + sid = _sid(row["Station"]) | ||||
| 90 | + for c in cols: | ||||
| 91 | + v = row.get(c) | ||||
| 92 | + if not v: | ||||
| 93 | + continue | ||||
| 94 | + try: | ||||
| 95 | + x = float(v) | ||||
| 96 | + except ValueError: | ||||
| 97 | + continue | ||||
| 98 | + if x < 0: | ||||
| 99 | + continue | ||||
| 100 | + a = acc.setdefault((sid, annee, _HOURLY[c]), [0.0, 0]) | ||||
| 101 | + a[0] += x | ||||
| 102 | + a[1] += 1 | ||||
| 103 | + n += 1 | ||||
| 104 | + print(f"[air] horaires {annee} : {n} mesures") | ||||
| 105 | + | ||||
| 106 | + n = 0 | ||||
| 107 | + for row in csv.DictReader(_dl(URL_SEQ)): | ||||
| 108 | + pol = _SEQ.get(row["Contaminant"]) | ||||
| 109 | + if not pol: | ||||
| 110 | + continue | ||||
| 111 | + annee = int(row["Date"][:4]) | ||||
| 112 | + if annee < 2022: | ||||
| 113 | + continue | ||||
| 114 | + v = row.get("Resultat") | ||||
| 115 | + if not v: | ||||
| 116 | + ld = row.get("LD") | ||||
| 117 | + if not ld: | ||||
| 118 | + continue | ||||
| 119 | + try: | ||||
| 120 | + x = float(ld) / 2.0 # convention < LD -> LD/2 | ||||
| 121 | + except ValueError: | ||||
| 122 | + continue | ||||
| 123 | + else: | ||||
| 124 | + try: | ||||
| 125 | + x = float(v) | ||||
| 126 | + except ValueError: | ||||
| 127 | + continue | ||||
| 128 | + a = acc.setdefault((_sid(row["Station"]), annee, pol), [0.0, 0]) | ||||
| 129 | + a[0] += x | ||||
| 130 | + a[1] += 1 | ||||
| 131 | + n += 1 | ||||
| 132 | + print(f"[air] séquentielles PST/PM10 : {n} mesures (≥ 2022)") | ||||
| 133 | + | ||||
| 134 | + con = sqlite3.connect(DB_PATH) | ||||
| 135 | + con.executescript(""" | ||||
| 136 | + DROP TABLE IF EXISTS air_stats; | ||||
| 137 | + CREATE TABLE air_stats (station TEXT, nom TEXT, ville TEXT, | ||||
| 138 | + lat REAL, lng REAL, annee INTEGER, polluant TEXT, | ||||
| 139 | + moyenne REAL, n INTEGER); | ||||
| 140 | + CREATE INDEX idx_air_latlng ON air_stats (lat, lng); | ||||
| 141 | + """) | ||||
| 142 | + now = time.strftime("%Y-%m-%d") | ||||
| 143 | + rows = [] | ||||
| 144 | + for (sid, annee, pol), (somme, cnt) in acc.items(): | ||||
| 145 | + st = stations.get(sid) | ||||
| 146 | + # minimum de couverture : 30 jours d'heures ou 15 échantillons | ||||
| 147 | + if not st or cnt < (720 if pol in ("PM2.5", "NO2", "O3", "SO2", "CO") | ||||
| 148 | + else 15): | ||||
| 149 | + continue | ||||
| 150 | + rows.append((sid, st["nom"], st["ville"], st["lat"], st["lng"], | ||||
| 151 | + annee, pol, round(somme / cnt, 2), cnt)) | ||||
| 152 | + with con: | ||||
| 153 | + con.executemany("INSERT INTO air_stats VALUES (?,?,?,?,?,?,?,?,?)", | ||||
| 154 | + rows) | ||||
| 155 | + con.execute("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v)") | ||||
| 156 | + con.execute("INSERT OR REPLACE INTO meta VALUES ('maj', ?)", (now,)) | ||||
| 157 | + print(f"[air] {len(rows)} agrégats station-année-polluant -> {DB_PATH}") | ||||
| 158 | + con.close() | ||||
| 159 | + | ||||
| 160 | + | ||||
| 161 | +def _dist_km(lat1, lng1, lat2, lng2) -> float: | ||||
| 162 | + dlat = math.radians(lat2 - lat1) | ||||
| 163 | + dlng = math.radians(lng2 - lng1) | ||||
| 164 | + a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) | ||||
| 165 | + * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2) | ||||
| 166 | + return 6371 * 2 * math.asin(math.sqrt(a)) | ||||
| 167 | + | ||||
| 168 | + | ||||
| 169 | +def lookup(lat: float, lng: float, max_km: float = 60.0) -> dict | None: | ||||
| 170 | + """Mesures de la station RSQAQ la plus proche (année la plus récente).""" | ||||
| 171 | + if not DB_PATH.exists(): | ||||
| 172 | + return None | ||||
| 173 | + con = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) | ||||
| 174 | + con.row_factory = sqlite3.Row | ||||
| 175 | + d = max_km / 111.0 | ||||
| 176 | + rows = con.execute( | ||||
| 177 | + "SELECT * FROM air_stats WHERE lat BETWEEN ? AND ? AND lng BETWEEN " | ||||
| 178 | + "? AND ?", (lat - d, lat + d, lng - d / max(0.2, math.cos( | ||||
| 179 | + math.radians(lat))), lng + d / max(0.2, math.cos( | ||||
| 180 | + math.radians(lat))))).fetchall() | ||||
| 181 | + con.close() | ||||
| 182 | + if not rows: | ||||
| 183 | + return {"station": None} | ||||
| 184 | + # station la plus proche disposant de données récentes | ||||
| 185 | + best: dict[str, dict] = {} | ||||
| 186 | + for r in rows: | ||||
| 187 | + km = _dist_km(lat, lng, r["lat"], r["lng"]) | ||||
| 188 | + if km > max_km: | ||||
| 189 | + continue | ||||
| 190 | + b = best.setdefault(r["station"], {"km": km, "rows": [], "r": r}) | ||||
| 191 | + b["rows"].append(r) | ||||
| 192 | + if not best: | ||||
| 193 | + return {"station": None} | ||||
| 194 | + sid, b = min(best.items(), key=lambda kv: kv[1]["km"]) | ||||
| 195 | + # par polluant : l'année la plus récente | ||||
| 196 | + mesures: dict[str, dict] = {} | ||||
| 197 | + for r in sorted(b["rows"], key=lambda r: -r["annee"]): | ||||
| 198 | + if r["polluant"] in mesures: | ||||
| 199 | + continue | ||||
| 200 | + ref = REFS.get(r["polluant"]) | ||||
| 201 | + mesures[r["polluant"]] = { | ||||
| 202 | + "moyenne": r["moyenne"], "annee": r["annee"], "n": r["n"], | ||||
| 203 | + "unite": UNITES.get(r["polluant"], ""), | ||||
| 204 | + "ref": ref[1] if ref else None, | ||||
| 205 | + "ref_nom": ref[0] if ref else None} | ||||
| 206 | + st = b["r"] | ||||
| 207 | + return {"station": st["nom"], "ville": st["ville"], | ||||
| 208 | + "distance_km": round(b["km"], 1), "mesures": mesures} | ||||
added
louka/gaz.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 | +# gaz.py : stations-service à proximité et prix de l'essence — gazquebec.ca | ||||
| 5 | +# | ||||
| 6 | +# gazquebec.ca expose un GeoJSON public de ~2 450 stations du Québec avec | ||||
| 7 | +# les prix courants par carburant (Régulier / Super / Diesel). On garde une | ||||
| 8 | +# copie locale (data/gaz.db) rafraîchie automatiquement au plus toutes les | ||||
| 9 | +# 4 h ; `nearby(lat, lng)` retourne les stations les plus proches avec | ||||
| 10 | +# leurs prix et la médiane du rayon pour situer chaque prix. | ||||
| 11 | +# ----------------------------------------------------------------------------- | ||||
| 12 | +from __future__ import annotations | ||||
| 13 | + | ||||
| 14 | +import json | ||||
| 15 | +import math | ||||
| 16 | +import sqlite3 | ||||
| 17 | +import time | ||||
| 18 | +import urllib.request | ||||
| 19 | +from pathlib import Path | ||||
| 20 | +from statistics import median | ||||
| 21 | + | ||||
| 22 | +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "gaz.db" | ||||
| 23 | +API = "https://gazquebec.ca/api/stations" | ||||
| 24 | +UA = "LouKaBot/1.0 (+https://www.lou-ka.com; contact@spboucher.ai)" | ||||
| 25 | +TTL = 4 * 3600 # fraîcheur maximale de la copie locale | ||||
| 26 | + | ||||
| 27 | + | ||||
| 28 | +def _connect() -> sqlite3.Connection: | ||||
| 29 | + DB_PATH.parent.mkdir(parents=True, exist_ok=True) | ||||
| 30 | + con = sqlite3.connect(DB_PATH, timeout=15) | ||||
| 31 | + con.row_factory = sqlite3.Row | ||||
| 32 | + con.executescript(""" | ||||
| 33 | + CREATE TABLE IF NOT EXISTS gaz_stations ( | ||||
| 34 | + id INTEGER PRIMARY KEY, nom TEXT, banniere TEXT, adresse TEXT, | ||||
| 35 | + region TEXT, lat REAL, lng REAL, | ||||
| 36 | + prix_regulier REAL, prix_super REAL, prix_diesel REAL); | ||||
| 37 | + CREATE INDEX IF NOT EXISTS idx_gaz_latlng ON gaz_stations (lat, lng); | ||||
| 38 | + CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v); | ||||
| 39 | + """) | ||||
| 40 | + return con | ||||
| 41 | + | ||||
| 42 | + | ||||
| 43 | +def refresh(con: sqlite3.Connection | None = None) -> int: | ||||
| 44 | + """Télécharge le GeoJSON des stations et remplace la copie locale.""" | ||||
| 45 | + own = con is None | ||||
| 46 | + con = con or _connect() | ||||
| 47 | + req = urllib.request.Request(API, headers={"User-Agent": UA}) | ||||
| 48 | + with urllib.request.urlopen(req, timeout=60) as r: | ||||
| 49 | + data = json.load(r) | ||||
| 50 | + rows = [] | ||||
| 51 | + for f in data.get("features", []): | ||||
| 52 | + try: | ||||
| 53 | + lng, lat = f["geometry"]["coordinates"][:2] | ||||
| 54 | + except (KeyError, TypeError, ValueError): | ||||
| 55 | + continue | ||||
| 56 | + p = f.get("properties") or {} | ||||
| 57 | + prix = {"Régulier": None, "Super": None, "Diesel": None} | ||||
| 58 | + try: | ||||
| 59 | + for e in json.loads(p.get("allPrices") or "[]"): | ||||
| 60 | + if e.get("fuelType") in prix and e.get("priceCents"): | ||||
| 61 | + prix[e["fuelType"]] = e["priceCents"] / 10.0 # ¢/L | ||||
| 62 | + except ValueError: | ||||
| 63 | + pass | ||||
| 64 | + rows.append((p.get("id"), p.get("name"), p.get("brand"), | ||||
| 65 | + p.get("address"), p.get("region"), lat, lng, | ||||
| 66 | + prix["Régulier"], prix["Super"], prix["Diesel"])) | ||||
| 67 | + with con: | ||||
| 68 | + con.execute("DELETE FROM gaz_stations") | ||||
| 69 | + con.executemany( | ||||
| 70 | + "INSERT OR REPLACE INTO gaz_stations VALUES (?,?,?,?,?,?,?,?,?,?)", | ||||
| 71 | + rows) | ||||
| 72 | + con.execute("INSERT OR REPLACE INTO meta VALUES ('maj', ?)", | ||||
| 73 | + (time.time(),)) | ||||
| 74 | + if own: | ||||
| 75 | + con.close() | ||||
| 76 | + return len(rows) | ||||
| 77 | + | ||||
| 78 | + | ||||
| 79 | +def _dist_m(lat1, lng1, lat2, lng2) -> float: | ||||
| 80 | + dlat = math.radians(lat2 - lat1) | ||||
| 81 | + dlng = math.radians(lng2 - lng1) | ||||
| 82 | + a = (math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) | ||||
| 83 | + * math.cos(math.radians(lat2)) * math.sin(dlng / 2) ** 2) | ||||
| 84 | + return 6371000 * 2 * math.asin(math.sqrt(a)) | ||||
| 85 | + | ||||
| 86 | + | ||||
| 87 | +def nearby(lat: float, lng: float, radius_m: int = 5000, | ||||
| 88 | + limit: int = 5) -> dict: | ||||
| 89 | + """Stations les plus proches + médiane du prix Régulier dans le rayon.""" | ||||
| 90 | + con = _connect() | ||||
| 91 | + row = con.execute("SELECT v FROM meta WHERE k='maj'").fetchone() | ||||
| 92 | + if row is None or time.time() - float(row["v"]) > TTL: | ||||
| 93 | + try: | ||||
| 94 | + refresh(con) | ||||
| 95 | + except Exception: | ||||
| 96 | + pass # copie périmée mieux que rien | ||||
| 97 | + d = radius_m / 111320.0 | ||||
| 98 | + dl = d / max(0.2, math.cos(math.radians(lat))) | ||||
| 99 | + rows = con.execute( | ||||
| 100 | + "SELECT * FROM gaz_stations WHERE lat BETWEEN ? AND ? " | ||||
| 101 | + "AND lng BETWEEN ? AND ?", | ||||
| 102 | + (lat - d, lat + d, lng - dl, lng + dl)).fetchall() | ||||
| 103 | + maj = con.execute("SELECT v FROM meta WHERE k='maj'").fetchone() | ||||
| 104 | + con.close() | ||||
| 105 | + hits = [] | ||||
| 106 | + for r in rows: | ||||
| 107 | + dist = _dist_m(lat, lng, r["lat"], r["lng"]) | ||||
| 108 | + if dist <= radius_m: | ||||
| 109 | + hits.append((dist, r)) | ||||
| 110 | + hits.sort(key=lambda t: t[0]) | ||||
| 111 | + regs = [r["prix_regulier"] for _, r in hits if r["prix_regulier"]] | ||||
| 112 | + med = round(median(regs), 1) if len(regs) >= 3 else None | ||||
| 113 | + mini = round(min(regs), 1) if regs else None | ||||
| 114 | + items = [{"nom": r["banniere"] or r["nom"], "adresse": r["adresse"], | ||||
| 115 | + "dist_m": round(dist), | ||||
| 116 | + "regulier": r["prix_regulier"], "super": r["prix_super"], | ||||
| 117 | + "diesel": r["prix_diesel"], | ||||
| 118 | + "moins_chere": bool(r["prix_regulier"] and mini | ||||
| 119 | + and r["prix_regulier"] <= mini)} | ||||
| 120 | + for dist, r in hits[:limit]] | ||||
| 121 | + return {"n": len(hits), "rayon_m": radius_m, "mediane_regulier": med, | ||||
| 122 | + "min_regulier": mini, "stations": items, | ||||
| 123 | + "maj": time.strftime("%Y-%m-%d %H:%M", | ||||
| 124 | + time.localtime(float(maj["v"]))) | ||||
| 125 | + if maj else None} | ||||
modified
louka/web.py
+17 −0
@@ -542,6 +542,23 @@ def fairvalue_detail(uid: str): | |||||
| 542 | return d | 542 | return d | ||
| 543 | 543 | ||||
| 544 | 544 | ||||
| 545 | +@app.get("/api/air") | ||||
| 546 | +def air_at(lat: float, lng: float): | ||||
| 547 | + """Qualité de l'air : station RSQAQ la plus proche (MELCCFP).""" | ||||
| 548 | + from . import air | ||||
| 549 | + d = air.lookup(lat, lng) | ||||
| 550 | + if d is None: | ||||
| 551 | + raise HTTPException(404, "Base qualité de l'air non disponible") | ||||
| 552 | + return d | ||||
| 553 | + | ||||
| 554 | + | ||||
| 555 | +@app.get("/api/gaz") | ||||
| 556 | +def gaz_at(lat: float, lng: float): | ||||
| 557 | + """Stations-service à proximité et prix courants (gazquebec.ca).""" | ||||
| 558 | + from . import gaz | ||||
| 559 | + return gaz.nearby(lat, lng) | ||||
| 560 | + | ||||
| 561 | + | ||||
| 545 | @app.get("/api/inondation") | 562 | @app.get("/api/inondation") | ||
| 546 | def inondation_at(lat: float, lng: float): | 563 | def inondation_at(lat: float, lng: float): | ||
| 547 | """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) — | 564 | """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) — | ||
modified
run.py
+6 −0
@@ -96,6 +96,12 @@ def main() -> None: | |||||
| 96 | elif cmd == "inondation-build": | 96 | elif cmd == "inondation-build": | ||
| 97 | from louka import inondation | 97 | from louka import inondation | ||
| 98 | inondation.build() | 98 | inondation.build() | ||
| 99 | + elif cmd == "air-refresh": | ||||
| 100 | + from louka import air | ||||
| 101 | + air.refresh() | ||||
| 102 | + elif cmd == "gaz-refresh": | ||||
| 103 | + from louka import gaz | ||||
| 104 | + print(f"[gaz] {gaz.refresh()} stations") | ||||
| 99 | elif cmd == "rdl": | 105 | elif cmd == "rdl": | ||
| 100 | from louka import rdl | 106 | from louka import rdl | ||
| 101 | rdl.refresh() | 107 | rdl.refresh() | ||
| 102 | 108 | ||||