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 | 29 | frontend/tsconfig.tsbuildinfo |
| 30 | 30 | data/rdl.db |
| 31 | 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 | 595 | `</svg>`; |
| 596 | 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 | 19 | import PriceAnalysis from "../components/PriceAnalysis"; |
| 20 | 20 | import RegistreLoyers from "../components/RegistreLoyers"; |
| 21 | 21 | import RisqueInondation from "../components/RisqueInondation"; |
| 22 | +import QualiteAir from "../components/QualiteAir"; | |
| 23 | +import EssenceProche from "../components/EssenceProche"; | |
| 22 | 24 | import { IcoAlert, IcoDoc } from "../components/Icons"; |
| 23 | 25 | import KaScoresBlock from "../components/KaScoresBlock"; |
| 24 | 26 | import { markSeen } from "../search/seen"; |
@@ -477,6 +479,10 @@ export default function ListingPage() { | ||
| 477 | 479 | |
| 478 | 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 | 486 | {l.kascores && <KaScoresBlock ks={l.kascores} />} |
| 481 | 487 | |
| 482 | 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 | 1932 | color: var(--ink); white-space: nowrap; } |
| 1933 | 1933 | .q-crime-n small { font-weight: 400; color: var(--ink-3); } |
| 1934 | 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 | 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 | 562 | @app.get("/api/inondation") |
| 546 | 563 | def inondation_at(lat: float, lng: float): |
| 547 | 564 | """Risque d'inondation à l'adresse (BDZI, gouv. du Québec) — |
modified
run.py
+6 −0
@@ -96,6 +96,12 @@ def main() -> None: | ||
| 96 | 96 | elif cmd == "inondation-build": |
| 97 | 97 | from louka import inondation |
| 98 | 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 | 105 | elif cmd == "rdl": |
| 100 | 106 | from louka import rdl |
| 101 | 107 | rdl.refresh() |
| 102 | 108 | |