Quartier : scripts de construction des données + centiles PMD + correctif débordement mobile
- scripts/build_{recensement,contexte,environnement}.py : pipelines rejouables
(StatCan recensement 2021 + limites AD, PMD, INSPQ défavorisation, écoles
MEQ/IMSE, îlots de chaleur INSPQ, actes criminels SPVM, IGC WDS)
- merge_quartier.py : fusion + rangs centiles québécois des scores PMD
(l'échelle nationale brute écrasait les valeurs urbaines)
- data/quartier.db (51 Mo, gitignoré) : déployé par rsync, reconstruit par
scripts — 11 317 AD, 49 791 actes SPVM 24 mois, 1 249 immeubles chaleur
- min-width:0 sur les enfants de .detail-grid (débordement mobile)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 9 changed files with +1,657 and −0
modified
.gitignore
+2 −0
@@ -7,3 +7,5 @@ frontend/node_modules/ | ||
| 7 | 7 | frontend/dist/ |
| 8 | 8 | .DS_Store |
| 9 | 9 | reponse.txt |
| 10 | +data/staging-*.db | |
| 11 | +data/quartier.db | |
modified
frontend/src/styles.css
+3 −0
@@ -264,6 +264,9 @@ img { display: block; } | ||
| 264 | 264 | .crumbs a:hover { color: var(--green); border-color: var(--green); } |
| 265 | 265 | .detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; } |
| 266 | 266 | @media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } } |
| 267 | +/* les enfants de grille ont min-width:auto par défaut : un contenu large | |
| 268 | + (grille du quartier) déborderait de l'écran mobile sans ceci */ | |
| 269 | +.detail-grid > * { min-width: 0; } | |
| 267 | 270 | |
| 268 | 271 | .gallery { display: flex; flex-direction: column; gap: 10px; } |
| 269 | 272 | .gallery-main { |
added
reports/quartier-contexte.md
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# Contexte de quartier — tables de proximité et socio-éducatives | |
| 2 | + | |
| 3 | +Base construite : `data/staging-contexte.db` (SQLite, ~2,3 Mo) | |
| 4 | +Script : `scripts/build_contexte.py` (reconstruction complète, sources téléchargées dans `/tmp/louka-ctx`, option `--clean`) | |
| 5 | +Date de construction : 2026-08-08 | |
| 6 | + | |
| 7 | +## Tables | |
| 8 | + | |
| 9 | +### 1. `da_pmd` — mesures de proximité par aire de diffusion (13 805 AD) | |
| 10 | + | |
| 11 | +Source : Statistique Canada, **Base de données sur les mesures de proximité (PMD) 2021**, no 17-26-0002, édition 2023 ([page StatCan](https://www150.statcan.gc.ca/n1/pub/17-26-0002/172600022023001-eng.htm), [gouvernement ouvert](https://open.canada.ca/data/en/dataset/3bac0d69-e46e-43fd-bd39-6e77f9a30319)). Licence du gouvernement ouvert – Canada. | |
| 12 | + | |
| 13 | +- Fichier national par **îlot de diffusion** (DBUID, ~498 500 lignes), lu en streaming. | |
| 14 | +- Filtre Québec : `DAUID` commençant par `24` (le fichier fournit directement la colonne DAUID = 8 premiers caractères du DBUID). | |
| 15 | +- **Échelle d'origine** : les colonnes `prox_idx_*` sont déjà **normalisées 0..1 par StatCan** (transformation min-max, à l'échelle nationale, des mesures brutes de proximité pondérées par la gravité/distance réseau). `..` = valeur indisponible. Aucune re-normalisation n'a été faite ; un garde-fou borne à [0, 1]. | |
| 16 | +- **Agrégation DB → AD** : moyenne **pondérée par la population de l'îlot (`DBPOP`)** des valeurs non nulles ; si aucun îlot peuplé de l'AD n'a de valeur, repli sur la moyenne simple des valeurs non nulles ; sinon `NULL`. | |
| 17 | + | |
| 18 | +| colonne | source PMD | non-null | min | max | | |
| 19 | +|---|---|---|---|---| | |
| 20 | +| prox_epicerie | prox_idx_grocery | 12 118 | 0.0002 | 0.6850 | | |
| 21 | +| prox_pharmacie | prox_idx_pharma | 8 153 | 0.0010 | 0.5124 | | |
| 22 | +| prox_garderie | prox_idx_childcare | 13 097 | 0.0003 | 0.9218 | | |
| 23 | +| prox_ecole_prim | prox_idx_educpri | 11 731 | 0.0025 | 0.6288 | | |
| 24 | +| prox_ecole_sec | prox_idx_educsec | 6 692 | 0.0029 | 0.5733 | | |
| 25 | +| prox_sante | prox_idx_health | 13 254 | 0.0000 | 0.3740 | | |
| 26 | +| prox_bibliotheque | prox_idx_lib | 5 132 | 0.0036 | 0.5912 | | |
| 27 | +| prox_parc | prox_idx_parks | 12 718 | 0.0001 | 0.8381 | | |
| 28 | +| prox_transport | prox_idx_transit | 9 218 | 0.0000 | 0.4293 | | |
| 29 | +| prox_emploi | prox_idx_emp | 13 681 | 0.0000 | 0.5869 | | |
| 30 | + | |
| 31 | +Les maximums < 1 sont normaux : la normalisation est nationale, les valeurs 1.0 se trouvent dans les centres les plus denses du Canada (Toronto/Vancouver), et l'agrégation par AD lisse les pointes. | |
| 32 | + | |
| 33 | +### 2. `da_defav` — défavorisation INSPQ 2021 (13 806 AD, 12 974 cotées) | |
| 34 | + | |
| 35 | +Source : INSPQ, **Indice de défavorisation matérielle et sociale 2021**, tableau d'équivalence complet, version avril 2024 ([Données Québec](https://www.donneesquebec.ca/recherche/dataset/indice-de-defavorisation-du-quebec-2021)). Licence CC-BY 4.0. | |
| 36 | + | |
| 37 | +- `quintile_materiel` / `quintile_social` = colonnes `QuintMat` / `QuintSoc`, **échelle Québec** : 1 = plus favorisé … 5 = plus défavorisé. 832 AD non cotées (`NULL`) : population trop faible, milieux institutionnels, données de recensement supprimées. | |
| 38 | +- **Surprise de format** : le jeu Données Québec ne publie le tableau d'équivalence qu'en **XLSX** (`1. TableCorrespondancesCompleteQuebec2021_fr.xlsx`, feuille « Données »), pas en CSV — lu en streaming avec `openpyxl` (mode read_only). Les AD scindées entre plusieurs CLSC apparaissent en doublon dans le tableau ; les quintiles « échelle Québec » y sont identiques (première occurrence conservée). | |
| 39 | + | |
| 40 | +### 3. `ecoles` — 2 664 écoles primaires/secondaires géocodées (100 %) | |
| 41 | + | |
| 42 | +Sources ([localisation MEQ-MES](https://www.donneesquebec.ca/recherche/dataset/localisation-des-etablissements-d-enseignement-du-reseau-scolaire-au-quebec), [indices de défavorisation MEQ](https://www.donneesquebec.ca/recherche/dataset/indices-de-defavorisation), CC-BY 4.0) : | |
| 43 | +`pps_public_ecole.csv` (1 ligne/immeuble), `pps_prive_installation.csv` + `pps_prive_etablissement.csv`, `defav_ecole_prim_public.csv` et `defav_ecole_sec_public.csv` (année scolaire **2025-2026**). | |
| 44 | + | |
| 45 | +- **Choix de clé (organisme vs bâtiment)** : `code` = **code d'ORGANISME MEQ** (`CD_ORGNS`, 7 chiffres, zéros de tête préservés), car c'est la clé des fichiers IMSE/SFR (`Code_Org`). Les écoles multi-bâtiments sont réduites à une ligne ; `lat`/`lng` = premier immeuble (public) ou première installation (privé) géocodé, WGS84 (`COORD_X_LL84` = longitude). | |
| 46 | +- Filtre : `PRIM=1` ou `SEC=1` (préscolaire seul, formation professionnelle et éducation des adultes exclus). `ordre` ∈ {primaire, secondaire, primaire-secondaire}. | |
| 47 | +- Répartition : public 2 434 (1 844 prim / 389 sec / 201 mixte), privé 230 (58 / 89 / 83). | |
| 48 | +- `imse` / `sfr` = déciles 1 (favorisé) à 10 (défavorisé). 2 287 écoles publiques avec IMSE (2 287 / 2 290 codes diffusés matchés = 99,9 % ; 3 codes du fichier défav absents de la localisation — écarts de millésime). `NULL` pour le privé (non publié) et pour les écoles publiques sous seuil de diffusion. Une école présente dans les fichiers primaire ET secondaire reçoit les déciles du fichier **primaire**. | |
| 49 | + | |
| 50 | +### 4. `meta` — 12 clés (sources, licences, notes méthodologiques) | |
| 51 | + | |
| 52 | +## Validations | |
| 53 | + | |
| 54 | +| exigence | résultat | | |
| 55 | +|---|---| | |
| 56 | +| da_pmd ~10-13 k AD 24* | **13 805** (l'ensemble des AD du QC 2021 est ~13,8 k) ✅ | | |
| 57 | +| valeurs 0..1 | toutes les colonnes dans [0.0000, 0.9218] ✅ | | |
| 58 | +| da_defav ≥ 90 % des AD urbaines | **95,6 %** (proxy urbain : AD avec mesure de transport en commun dans la PMD) ✅ | | |
| 59 | +| ecoles ~3 000 | **2 664** — après exclusion presc/FP/adultes et regroupement multi-bâtiments par organisme ✅ (2 434 pub + 230 priv, 100 % géocodées) | | |
| 60 | +| IMSE plausible | Limoilou : Stadacona **9**, Saint-Paul-Apôtre **7** ; Sillery/Ste-Foy : Les Sources **1**, De Rochebelle **2** ✅ | | |
| 61 | + | |
| 62 | +**Nuance intéressante** : École Saint-Fidèle (Limoilou) affiche IMSE **2** — vérifié conforme au CSV source 2025-2026 (IMSE brut 3,88, SFR décile 8). La gentrification de Limoilou se lit dans les millésimes récents ; le contraste attendu reste net sur Stadacona/Saint-Paul-Apôtre. | |
| 63 | + | |
| 64 | +## Surprises de format rencontrées | |
| 65 | + | |
| 66 | +1. `PMD-en.csv` est encodé en **Latin-1** (pas UTF-8) — noms de lieux accentués. | |
| 67 | +2. Le tableau d'équivalence INSPQ n'existe qu'en **XLSX** (pas de CSV publié). | |
| 68 | +3. Les mesures PMD manquantes sont codées `..` ; `prox_bibliotheque` et `prox_ecole_sec` sont les plus creuses (~37 % et ~48 % de non-null seulement). | |
| 69 | +4. Fichiers privés MEQ : les ordres d'enseignement (PRESC/PRIM/SEC) ne figurent que dans le fichier **installations**, pas dans le fichier établissements — jointure via `CD_ORGNS_RESP`. | |
| 70 | + | |
| 71 | +## Attributions | |
| 72 | + | |
| 73 | +- Contient de l'information visée par la **Licence du gouvernement ouvert – Canada** (Statistique Canada, PMD 2021). | |
| 74 | +- « Indice de défavorisation matérielle et sociale 2021 », INSPQ — **CC-BY 4.0**. | |
| 75 | +- Données du ministère de l'Éducation du Québec (localisation des établissements ; indices IMSE/SFR) — **CC-BY 4.0**, Gouvernement du Québec. | |
added
reports/quartier-environnement.md
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +# Enrichissement quartier — îlots de chaleur et criminalité | |
| 2 | + | |
| 3 | +Base produite : `data/staging-environnement.db` (3,4 Mo) — générée le 2026-08-08 | |
| 4 | +par `scripts/build_environnement.py`. | |
| 5 | + | |
| 6 | +## Tables | |
| 7 | + | |
| 8 | +| Table | Lignes | Contenu | | |
| 9 | +|---|---|---| | |
| 10 | +| `heat` | 1 249 | classe ICFU (1-9) + écart de température (°C) par coordonnée unique d'immeuble | | |
| 11 | +| `crime_mtl` | 49 791 | actes criminels SPVM des 24 derniers mois (2024-08-08 → 2026-08-06) | | |
| 12 | +| `igc` | 35 | indice de gravité de la criminalité + taux, 7 services × 5 ans (2021-2025) | | |
| 13 | +| `meta` | 18 | sources, licences, dates de téléchargement | | |
| 14 | + | |
| 15 | +## 1. Îlots de chaleur (INSPQ/CERFO 2020-2022, CC-BY 4.0) | |
| 16 | + | |
| 17 | +- **Clé** : `coord_key` = `printf('%.4f',ROUND(lat,4))||','||printf('%.4f',ROUND(lng,4))` | |
| 18 | + — parité vérifiée avec `louka.db` : 1 249 / 1 249 clés identiques, aucune manquante. | |
| 19 | +- **Sens des classes (vérifié dans les métadonnées Données Québec)** : le classement | |
| 20 | + est fait en 9 niveaux des écarts de température par centre de population ; | |
| 21 | + **classes 1-2-3 = îlots de fraîcheur** (1 = le plus frais), **classes 8-9 = | |
| 22 | + îlots de chaleur** (9 = le plus chaud). Le classement étant relatif à chaque | |
| 23 | + centre de population, comparer aussi `ecart` (°C, échelle écoumène) entre villes. | |
| 24 | +- **Couverture** : classe 1 243/1 249 (**99,5 %**) ; écart 1 249/1 249 (**100 %**). | |
| 25 | + Les 6 classes NULL sont hors des centres de population couverts (Beauharnois, | |
| 26 | + Sainte-Barbe, Saint-Louis-de-Gonzague…) ou sur un pixel non classé en bord de | |
| 27 | + rivière (Pointe-aux-Lièvres, Québec) — l'écart reste disponible partout. | |
| 28 | + | |
| 29 | +Distribution des classes (immeubles urbains → biaisée vers le chaud, attendu) : | |
| 30 | + | |
| 31 | +| Classe | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | NULL | | |
| 32 | +|---|---|---|---|---|---|---|---|---|---| | |
| 33 | +| Coords | 1 | 8 | 50 | 143 | 159 | 261 | 347 | 274 | 6 | | |
| 34 | + | |
| 35 | +Écart de température : min 4,0 °C, moyenne 11,7 °C, max 18,3 °C. | |
| 36 | + | |
| 37 | +Contrôles de plausibilité : | |
| 38 | +- Centre-ville de Montréal (Ste-Catherine/Peel) : classe 7, écart 11,3 °C ; | |
| 39 | + centre-ville de Québec : classe 9, écart 15,5 °C — chauds. ✓ | |
| 40 | +- Sommet du parc du Mont-Royal : classe 2, écart 3,6 °C ; plaines d'Abraham : | |
| 41 | + classe 4, écart 6,0 °C — frais. ✓ | |
| 42 | +- Immeubles réels près de parcs classés frais : 3500 Ridgewood (flanc du | |
| 43 | + Mont-Royal) classe 3 ; 1 Severn Ave (Westmount) classe 2. ✓ | |
| 44 | + | |
| 45 | +Technique : échantillonnage rasterio par fenêtres (`ds.sample`, masked), points | |
| 46 | +reprojetés WGS84 → EPSG:32198 (Québec Lambert) avec pyproj ; rasters jamais | |
| 47 | +chargés en mémoire (le raster d'écarts fait 9,5 Go, résolution 15 m). | |
| 48 | + | |
| 49 | +## 2. Criminalité Montréal (SPVM, CC-BY 4.0) | |
| 50 | + | |
| 51 | +- Source : jeu « Actes criminels » de la Ville de Montréal (MAJ quotidienne) ; | |
| 52 | + fenêtre glissante de 24 mois appliquée à la date d'exécution. | |
| 53 | +- 59 274 actes dans la fenêtre, **49 791 conservés** avec coordonnées valides | |
| 54 | + (le SPVM masque la localisation d'environ 16 % des actes, coordonnées vides). | |
| 55 | +- `ts` = epoch UTC (minuit) de la date de l'acte ; `categorie` telle quelle | |
| 56 | + (6 catégories : vol dans/sur véhicule, introduction, vol de véhicule, méfait, | |
| 57 | + vols qualifiés, infractions entraînant la mort). | |
| 58 | +- **Test de comptage** : actes à < 500 m de (45.5088, -73.5617) — centre-ville — | |
| 59 | + sur les 12 derniers mois : **487 actes**. | |
| 60 | +- Rafraîchissement mensuel : `.venv/bin/python scripts/build_environnement.py --etapes crime` | |
| 61 | + | |
| 62 | +## 3. Indice de gravité de la criminalité (Statistique Canada, licence ouverte) | |
| 63 | + | |
| 64 | +- `indice` : IGC global — tableau 35-10-0187 (par service de police) et | |
| 65 | + 35-10-0026 (Canada). `taux` : taux de criminalité par 100 000 hab. | |
| 66 | + (infractions au Code criminel sauf délits de la route) — tableaux 35-10-0179 / | |
| 67 | + 35-10-0177 via l'API WDS. | |
| 68 | +- 7 services × 5 ans (2021-2025), aucun NULL. Valeurs 2025 (IGC / taux) : | |
| 69 | + | |
| 70 | +| Service | IGC 2025 | Taux 2025 | | |
| 71 | +|---|---|---| | |
| 72 | +| SPVM (Montréal) | 78,35 | 4 803 | | |
| 73 | +| SPVQ (Ville de Québec) | 63,48 | 4 139 | | |
| 74 | +| Laval | 52,88 | 3 204 | | |
| 75 | +| Longueuil (agglomération) | 52,76 | 2 778 | | |
| 76 | +| Ville de Lévis | 35,03 | 1 876 | | |
| 77 | +| Québec (province) | 61,55 | 3 673 | | |
| 78 | +| Canada | 75,01 | 5 585 | | |
| 79 | + | |
| 80 | +SPVM 78-81 sur la période (plausible, 60-90) ; SPVQ 49→63 (hausse réelle | |
| 81 | +observée depuis 2021). Lévis nettement sous la moyenne provinciale. | |
| 82 | + | |
| 83 | +## Attributions (à afficher avec les données) | |
| 84 | + | |
| 85 | +- Îlots de chaleur/fraîcheur et écarts de température 2020-2022 : **INSPQ et | |
| 86 | + CERFO**, Données Québec, CC-BY 4.0. | |
| 87 | +- Actes criminels : **Ville de Montréal (SPVM)**, CC-BY 4.0. | |
| 88 | +- IGC et taux de criminalité : adapté de **Statistique Canada**, tableaux | |
| 89 | + 35-10-0187, 35-10-0026, 35-10-0179, 35-10-0177 (licence ouverte) ; cela ne | |
| 90 | + constitue pas une approbation par Statistique Canada de ce produit. | |
| 91 | + | |
| 92 | +## Reproduire / mettre à jour | |
| 93 | + | |
| 94 | +```bash | |
| 95 | +.venv/bin/python scripts/build_environnement.py # tout | |
| 96 | +.venv/bin/python scripts/build_environnement.py --etapes crime # criminalité (mensuel) | |
| 97 | +.venv/bin/python scripts/build_environnement.py --etapes chaleur --coords nouvelles.txt | |
| 98 | +# nouvelles.txt : une paire « lat,lng » par ligne (upsert dans heat) | |
| 99 | +``` | |
| 100 | + | |
| 101 | +Les GeoTIFF (~9,6 Go) sont téléchargés dans `/tmp/louka-env` puis supprimés | |
| 102 | +(option `--garder-cache` pour les conserver). | |
added
reports/quartier-recensement.md
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +# Recensement 2021 — tables « aires de diffusion » (staging-recensement.db) | |
| 2 | + | |
| 3 | +Construit le 2026-08-08 par `scripts/build_recensement.py`. | |
| 4 | +Livrable : `data/staging-recensement.db` (tables `da_poly`, `da_stats`, `meta`). | |
| 5 | + | |
| 6 | +## Sources | |
| 7 | + | |
| 8 | +| Donnée | Source | URL | | |
| 9 | +|---|---|---| | |
| 10 | +| Polygones des AD | Statistique Canada, Fichiers des limites **cartographiques** 2021 (92-160-X), aires de diffusion, `lda_000b21a_e.zip` (197 Mo, shapefile) | https://www12.statcan.gc.ca/census-recensement/2021/geo/sip-pis/boundary-limites/files-fichiers/lda_000b21a_e.zip | | |
| 11 | +| Statistiques | Profil du recensement 2021, **98-401-X2021006**, fichier « Québec » au niveau des aires de diffusion (CSV français, 6,5 Go décompressé) | https://www12.statcan.gc.ca/census-recensement/2021/dp-pd/prof/details/download-telecharger/comp/GetFile.cfm?Lang=F&FILETYPE=CSV&GEONO=006_Quebec | | |
| 12 | + | |
| 13 | +Attribution (licence ouverte de Statistique Canada) : **« Statistique Canada, | |
| 14 | +Recensement de la population de 2021 »**. | |
| 15 | + | |
| 16 | +## Méthodologie | |
| 17 | + | |
| 18 | +### Polygones (`da_poly`) | |
| 19 | + | |
| 20 | +- Lecture du shapefile avec **pyshp** ; le fichier StatCan est projeté en | |
| 21 | + Lambert conforme conique « NAD83 Statistics Canada Lambert » (**EPSG:3347**), | |
| 22 | + reprojeté vers **WGS84 (EPSG:4326)** avec pyproj. | |
| 23 | +- Filtres : `PRUID = 24` (Québec), puis intersection de la bbox WGS84 de | |
| 24 | + chaque AD avec la zone de couverture des annonces Lou-Ka : | |
| 25 | + **lat 44,9–47,7 / lng −74,6–−70,2** (Québec, Lévis, Grand Montréal + | |
| 26 | + périphérie). Pré-filtre grossier sur la bbox projetée pour éviter de | |
| 27 | + reprojeter les géométries hors zone. | |
| 28 | +- `poly` = JSON, liste d'anneaux `[[lng, lat], …]` ; le 1er anneau d'un | |
| 29 | + polygone shapefile est l'anneau extérieur, les suivants sont les trous ou | |
| 30 | + les autres parties (multi-polygones). Un test point-dans-polygone | |
| 31 | + **pair/impair sur l'ensemble des anneaux** gère correctement trous et | |
| 32 | + multi-parties. Coordonnées arrondies à 6 décimales (~10 cm). | |
| 33 | + | |
| 34 | +### Statistiques (`da_stats`) | |
| 35 | + | |
| 36 | +Le CSV de 6,5 Go est **streamé** (module `csv`, encodage latin-1, jamais chargé | |
| 37 | +en mémoire). Les `ID_CARACTÉRISTIQUE` sont découverts dynamiquement en scannant | |
| 38 | +la liste des caractéristiques de la première géographie du fichier (robuste aux | |
| 39 | +renumérotations) ; les noms ambigus (« Français », « Locataire », | |
| 40 | +« Baccalauréat ou grade supérieur » apparaissent plusieurs fois) sont résolus | |
| 41 | +par position : première occurrence après la ligne « Total » de leur section. | |
| 42 | + | |
| 43 | +Variables retenues (valeur = colonne `C1_CHIFFRE_TOTAL`) : | |
| 44 | + | |
| 45 | +| Colonne | Caractéristique du profil (ID 2021) | Calcul | | |
| 46 | +|---|---|---| | |
| 47 | +| `population` | Population, 2021 (**1**) | direct | | |
| 48 | +| `densite` | Densité de la population au kilomètre carré (**6**) | direct | | |
| 49 | +| `age_median` | Âge médian de la population (**40**) | direct | | |
| 50 | +| `revenu_median` | Revenu total médian des ménages en 2020 ($) (**243**) | direct | | |
| 51 | +| `pct_locataires` | Locataire (**1416**) / Total ménages privés selon le mode d'occupation (**1414**) | 100 × 1416 ÷ 1414 | | |
| 52 | +| `loyer_moyen` | Frais de logement mensuels moyens pour les logements occupés par un ménage locataire ($) (**1495**) | direct | | |
| 53 | +| `pct_francais` | Français (**739**) / Total langue parlée le plus souvent à la maison (**735**) | 100 × 739 ÷ 735 | | |
| 54 | +| `pct_univ` | Baccalauréat ou grade supérieur (**2024**) / Total plus haut certificat, diplôme ou grade, 25–64 ans (**2014**) | 100 × 2024 ÷ 2014 | | |
| 55 | + | |
| 56 | +Notes : | |
| 57 | +- `loyer_moyen` = « frais de logement mensuels moyens » des ménages locataires | |
| 58 | + (loyer + électricité/chauffage/services municipaux, définition StatCan) ; | |
| 59 | + c'est la mesure standard du coût mensuel des logements loués dans le profil. | |
| 60 | +- Valeurs supprimées par StatCan (confidentialité : symboles `x`, `F`, `..`, | |
| 61 | + `...`) → **NULL** ; les pourcentages dont le numérateur ou le dénominateur | |
| 62 | + est supprimé ou nul → **NULL**. Rien n'est imputé. | |
| 63 | +- Revenu et scolarité proviennent des **données-échantillon (25 %)** ; | |
| 64 | + population, âge et langue des données intégrales (100 %). | |
| 65 | + | |
| 66 | +## Validations (exécutées le 2026-08-08, toutes réussies) | |
| 67 | + | |
| 68 | +- **11 317 AD** dans `da_poly` (sur 13 805 AD au Québec) — dans la fourchette | |
| 69 | + attendue (8 000–13 000). Population couverte : **7 186 380** habitants. | |
| 70 | +- **Couverture `da_stats` : 11 317 / 11 317 = 100 %** des DAUID de `da_poly`. | |
| 71 | +- **Point de contrôle** : (46.8139, −71.2329) → DAUID **24231035** ✓ | |
| 72 | + (identique au résultat de l'API ArcGIS StatCan, | |
| 73 | + `geo.statcan.gc.ca/geo_wa/rest/services/2021/Cartographic_boundary_files/MapServer/12`). | |
| 74 | +- **Échantillon de plausibilité** (point-dans-polygone puis lecture des stats, | |
| 75 | + colonnes : revenu_median / pct_locataires / loyer_moyen / pct_francais / pct_univ) : | |
| 76 | + | |
| 77 | + | Secteur | DAUID | Revenu médian | % locataires | Loyer moyen | % français | % univ | | |
| 78 | + |---|---|---|---|---|---|---| | |
| 79 | + | Saint-Roch (Québec) | 24231032 | 65 000 $ | **79,3 %** | 1 136 $ | 93,3 % | 68,0 % | | |
| 80 | + | Sillery (Québec) | 24230224 | **137 000 $** | **14,8 %** | 1 200 $ | 93,6 % | 61,7 % | | |
| 81 | + | Plateau Mont-Royal (Mtl) | 24661965 | 57 200 $ | 76,7 % | 1 230 $ | 63,6 % | 76,6 % | | |
| 82 | + | |
| 83 | + Cohérent : Saint-Roch et le Plateau très locatifs, Sillery aisé et | |
| 84 | + propriétaire, français plus faible sur le Plateau. | |
| 85 | +- **Valeurs manquantes (suppression StatCan)** : revenu_median NULL pour 234 AD, | |
| 86 | + loyer_moyen NULL pour 2 095 AD (AD avec peu ou pas de locataires), | |
| 87 | + pct_locataires/pct_univ NULL pour 118 AD — laissées NULL, rien d'imputé. | |
| 88 | +- Taille du livrable : `data/staging-recensement.db` ≈ **45 Mo**. | |
| 89 | + | |
| 90 | +Note : certaines AD rurales très étendues qui intersectent la zone débordent | |
| 91 | +au nord (lat_max jusqu'à ~49,0) — comportement attendu du filtre par | |
| 92 | +intersection de bbox. | |
| 93 | + | |
| 94 | +## Reproduire | |
| 95 | + | |
| 96 | +```bash | |
| 97 | +# 1) télécharger les deux sources dans /tmp (URLs ci-dessus), dézipper | |
| 98 | +# 2) construire : | |
| 99 | +.venv/bin/python scripts/build_recensement.py \ | |
| 100 | + --shp /tmp/louka-recensement/lda/lda_000b21a_e.shp \ | |
| 101 | + --csv /tmp/louka-recensement/profil/98-401-X2021006_Francais_CSV_data_Quebec.csv \ | |
| 102 | + --db data/staging-recensement.db --etape tout | |
| 103 | +``` | |
added
scripts/build_contexte.py
+454 −0
@@ -0,0 +1,454 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# -*- coding: utf-8 -*- | |
| 3 | +""" | |
| 4 | +build_contexte.py — Construit data/staging-contexte.db : tables « proximité et | |
| 5 | +contexte socio-éducatif » pour l'enrichissement des fiches Lou-Ka. | |
| 6 | + | |
| 7 | +Tables produites (schéma contractuel) : | |
| 8 | + 1. da_pmd — mesures de proximité StatCan (PMD 2021, parution 2023) agrégées | |
| 9 | + par aire de diffusion (AD, DAUID 24*), valeurs normalisées 0..1. | |
| 10 | + 2. da_defav — quintiles de défavorisation matérielle/sociale INSPQ 2021 par AD | |
| 11 | + (échelle Québec, 1 = favorisé, 5 = défavorisé). | |
| 12 | + 3. ecoles — écoles primaires/secondaires (public + privé) du Québec avec | |
| 13 | + coordonnées MEQ et déciles IMSE/SFR (public seulement). | |
| 14 | + 4. meta — sources, dates, licences. | |
| 15 | + | |
| 16 | +Sources (téléchargées dans /tmp/louka-ctx si absentes, puis nettoyées avec --clean) : | |
| 17 | + * PMD : Base de données sur les mesures de proximité, StatCan 17-26-0002, | |
| 18 | + édition 2023 (données 2021). CSV national par îlot de diffusion (DBUID). | |
| 19 | + Les mesures prox_idx_* sont DÉJÀ normalisées entre 0 et 1 à l'échelle | |
| 20 | + nationale par StatCan (transformation min-max des mesures brutes de | |
| 21 | + proximité) ; « .. » = valeur indisponible. On agrège par AD (colonne DAUID | |
| 22 | + fournie = 8 premiers caractères du DBUID) en moyenne pondérée par la | |
| 23 | + population de l'îlot (DBPOP) ; si aucun îlot peuplé n'a de valeur, moyenne | |
| 24 | + simple des valeurs non nulles. | |
| 25 | + * INSPQ : Indice de défavorisation matérielle et sociale 2021 — tableau | |
| 26 | + d'équivalence complet « TableCorrespondancesCompleteQuebec2021_fr.xlsx » | |
| 27 | + (le jeu Données Québec ne publie le tableau d'équivalence qu'en XLSX, pas | |
| 28 | + en CSV ; on lit le XLSX en streaming avec openpyxl read_only). Colonnes | |
| 29 | + QuintMat / QuintSoc = quintiles à l'échelle Québec. | |
| 30 | + * MEQ écoles : CSV « Écoles publiques » (pps_public_ecole.csv, 1 ligne par | |
| 31 | + immeuble) + « Installations privées » (pps_prive_installation.csv, 1 ligne | |
| 32 | + par installation) + « Établissements privés » (noms officiels). Coordonnées | |
| 33 | + COORD_X_LL84 (longitude) / COORD_Y_LL84 (latitude), WGS84. | |
| 34 | + * MEQ IMSE/SFR : CSV « Défavorisation - Écoles primaires/secondaires | |
| 35 | + 2025-2026 » — déciles 1 (favorisé) à 10 (défavorisé). | |
| 36 | + | |
| 37 | +Choix de jointure écoles ↔ IMSE/SFR (documenté) : | |
| 38 | + Les fichiers IMSE/SFR sont publiés par CODE D'ORGANISME (Code_Org, 7 chiffres, | |
| 39 | + ex. 711002 = l'école en tant qu'entité administrative), pas par code de | |
| 40 | + bâtiment (CD_IMM). On agrège donc pps_public_ecole.csv par CD_ORGNS (= code | |
| 41 | + d'organisme de l'école) et on joint sur Code_Org == CD_ORGNS. Une école | |
| 42 | + multi-bâtiments donne 1 ligne ; ses coordonnées sont celles du premier | |
| 43 | + immeuble géocodé du fichier MEQ. Une école présente dans les deux fichiers | |
| 44 | + IMSE (primaire ET secondaire) reçoit en priorité les déciles du fichier | |
| 45 | + primaire. Le privé n'a pas d'IMSE/SFR publié → NULL. | |
| 46 | + | |
| 47 | +Usage : | |
| 48 | + .venv/bin/python scripts/build_contexte.py [--clean] | |
| 49 | + (--clean : supprime /tmp/louka-ctx à la fin) | |
| 50 | +""" | |
| 51 | + | |
| 52 | +import csv | |
| 53 | +import glob | |
| 54 | +import io | |
| 55 | +import os | |
| 56 | +import sqlite3 | |
| 57 | +import sys | |
| 58 | +import urllib.request | |
| 59 | +import zipfile | |
| 60 | +from collections import defaultdict | |
| 61 | +from datetime import date | |
| 62 | + | |
| 63 | +# --------------------------------------------------------------------------- | |
| 64 | +# Chemins et URLs | |
| 65 | +# --------------------------------------------------------------------------- | |
| 66 | +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | |
| 67 | +DB_PATH = os.path.join(REPO, "data", "staging-contexte.db") | |
| 68 | +TMP = "/tmp/louka-ctx" | |
| 69 | + | |
| 70 | +URLS = { | |
| 71 | + # StatCan PMD 2021 (parution 2023) — zip ~25 Mo contenant PMD-en.csv | |
| 72 | + "pmd-eng.zip": "https://www150.statcan.gc.ca/pub/17-26-0002/2023001/csv/pmd-eng.zip", | |
| 73 | + # INSPQ IDMS 2021, échelle Québec — zip contenant le tableau d'équivalence XLSX | |
| 74 | + "inspq2021.zip": "https://www.inspq.qc.ca/sites/default/files/IDMS/Qu%C3%A9bec2021_042024.zip", | |
| 75 | + # MEQ localisation des établissements | |
| 76 | + "pps_public_ecole.csv": "https://www.donneesquebec.ca/recherche/dataset/2d3b5cf8-b347-49c7-ad3b-bd6a9c15e443/resource/c6640a54-bc4b-43ec-864e-6c325dce61bc/download/pps_public_ecole.csv", | |
| 77 | + "pps_prive_installation.csv": "https://www.donneesquebec.ca/recherche/dataset/2d3b5cf8-b347-49c7-ad3b-bd6a9c15e443/resource/e22aa6f1-c4ff-4896-9534-6fa65133b3e9/download/pps_prive_installation.csv", | |
| 78 | + "pps_prive_etablissement.csv": "https://www.donneesquebec.ca/recherche/dataset/2d3b5cf8-b347-49c7-ad3b-bd6a9c15e443/resource/83aae1b5-87b5-4e3d-9074-c0d4778fe812/download/pps_prive_etablissement.csv", | |
| 79 | + # MEQ indices de défavorisation scolaire (IMSE/SFR) | |
| 80 | + "defav_prim.csv": "https://www.donneesquebec.ca/recherche/dataset/004de02c-19f1-4da0-9af8-33f893e41972/resource/6c5d4a5d-ba3b-40a6-b570-916f43ab622c/download/defav_ecole_prim_public.csv", | |
| 81 | + "defav_sec.csv": "https://www.donneesquebec.ca/recherche/dataset/004de02c-19f1-4da0-9af8-33f893e41972/resource/3e9aa43a-c32b-4779-b258-8407db716813/download/defav_ecole_sec_public.csv", | |
| 82 | +} | |
| 83 | + | |
| 84 | +# Correspondance colonne PMD -> colonne du schéma contractuel | |
| 85 | +PMD_COLS = [ | |
| 86 | + ("prox_idx_grocery", "prox_epicerie"), | |
| 87 | + ("prox_idx_pharma", "prox_pharmacie"), | |
| 88 | + ("prox_idx_childcare", "prox_garderie"), | |
| 89 | + ("prox_idx_educpri", "prox_ecole_prim"), | |
| 90 | + ("prox_idx_educsec", "prox_ecole_sec"), | |
| 91 | + ("prox_idx_health", "prox_sante"), | |
| 92 | + ("prox_idx_lib", "prox_bibliotheque"), | |
| 93 | + ("prox_idx_parks", "prox_parc"), | |
| 94 | + ("prox_idx_transit", "prox_transport"), | |
| 95 | + ("prox_idx_emp", "prox_emploi"), | |
| 96 | +] | |
| 97 | + | |
| 98 | + | |
| 99 | +def telecharger(): | |
| 100 | + """Télécharge les fichiers sources manquants dans /tmp/louka-ctx.""" | |
| 101 | + os.makedirs(TMP, exist_ok=True) | |
| 102 | + for nom, url in URLS.items(): | |
| 103 | + dest = os.path.join(TMP, nom) | |
| 104 | + if os.path.exists(dest) and os.path.getsize(dest) > 0: | |
| 105 | + continue | |
| 106 | + print(f" téléchargement {nom} …") | |
| 107 | + req = urllib.request.Request(url, headers={"User-Agent": "lou-ka/1.0"}) | |
| 108 | + with urllib.request.urlopen(req, timeout=300) as r, open(dest, "wb") as f: | |
| 109 | + while True: | |
| 110 | + bloc = r.read(1 << 20) | |
| 111 | + if not bloc: | |
| 112 | + break | |
| 113 | + f.write(bloc) | |
| 114 | + # dézippage (idempotent) | |
| 115 | + if not glob.glob(os.path.join(TMP, "pmd", "**", "PMD-en.csv"), recursive=True): | |
| 116 | + with zipfile.ZipFile(os.path.join(TMP, "pmd-eng.zip")) as z: | |
| 117 | + z.extractall(os.path.join(TMP, "pmd")) | |
| 118 | + if not glob.glob(os.path.join(TMP, "inspq", "**", "*TableCorrespondances*"), | |
| 119 | + recursive=True): | |
| 120 | + with zipfile.ZipFile(os.path.join(TMP, "inspq2021.zip")) as z: | |
| 121 | + z.extractall(os.path.join(TMP, "inspq")) | |
| 122 | + | |
| 123 | + | |
| 124 | +# --------------------------------------------------------------------------- | |
| 125 | +# 1. da_pmd — agrégation des îlots (DB) vers les aires de diffusion (AD) | |
| 126 | +# --------------------------------------------------------------------------- | |
| 127 | +def construire_da_pmd(cx): | |
| 128 | + chemin = glob.glob(os.path.join(TMP, "pmd", "**", "PMD-en.csv"), | |
| 129 | + recursive=True)[0] | |
| 130 | + # Accumulateurs par DAUID : pour chaque mesure, (somme pondérée, somme des | |
| 131 | + # poids, somme simple, compte) — le fichier national (~500 k lignes) est lu | |
| 132 | + # en streaming, on ne garde en mémoire que les AD du Québec (~13-14 k). | |
| 133 | + acc = defaultdict(lambda: [[0.0, 0.0, 0.0, 0] for _ in PMD_COLS]) | |
| 134 | + # NB : le CSV PMD est encodé en Latin-1 (noms de lieux accentués), pas UTF-8. | |
| 135 | + with open(chemin, newline="", encoding="latin-1") as f: | |
| 136 | + lecteur = csv.DictReader(f) | |
| 137 | + for ligne in lecteur: | |
| 138 | + dauid = ligne["DAUID"].strip() | |
| 139 | + if not dauid.startswith("24"): # Québec seulement (PRUID 24) | |
| 140 | + continue | |
| 141 | + # Poids = population de l'îlot de diffusion (DBPOP) ; vide ou 0 = | |
| 142 | + # îlot non peuplé, il ne contribue qu'à la moyenne simple de repli. | |
| 143 | + try: | |
| 144 | + poids = float(ligne["DBPOP"] or 0.0) | |
| 145 | + except ValueError: | |
| 146 | + poids = 0.0 | |
| 147 | + a = acc[dauid] | |
| 148 | + for i, (col_src, _) in enumerate(PMD_COLS): | |
| 149 | + brut = ligne[col_src].strip() | |
| 150 | + if brut in ("", "..", "F", "x"): # valeur indisponible | |
| 151 | + continue | |
| 152 | + try: | |
| 153 | + v = float(brut) | |
| 154 | + except ValueError: | |
| 155 | + continue | |
| 156 | + cell = a[i] | |
| 157 | + if poids > 0: | |
| 158 | + cell[0] += poids * v | |
| 159 | + cell[1] += poids | |
| 160 | + cell[2] += v | |
| 161 | + cell[3] += 1 | |
| 162 | + | |
| 163 | + lignes = [] | |
| 164 | + for dauid, a in acc.items(): | |
| 165 | + vals = [] | |
| 166 | + for wsum, wtot, ssum, n in a: | |
| 167 | + if wtot > 0: | |
| 168 | + v = wsum / wtot # moyenne pondérée par la population | |
| 169 | + elif n > 0: | |
| 170 | + v = ssum / n # repli : moyenne simple des non-nuls | |
| 171 | + else: | |
| 172 | + v = None # aucune valeur disponible dans l'AD | |
| 173 | + if v is not None: | |
| 174 | + v = min(1.0, max(0.0, v)) # garde-fou 0..1 | |
| 175 | + vals.append(v) | |
| 176 | + lignes.append((dauid, *vals)) | |
| 177 | + | |
| 178 | + cx.executemany( | |
| 179 | + "INSERT INTO da_pmd VALUES (?,?,?,?,?,?,?,?,?,?,?)", lignes) | |
| 180 | + return len(lignes) | |
| 181 | + | |
| 182 | + | |
| 183 | +# --------------------------------------------------------------------------- | |
| 184 | +# 2. da_defav — quintiles INSPQ (échelle Québec) par AD | |
| 185 | +# --------------------------------------------------------------------------- | |
| 186 | +def construire_da_defav(cx): | |
| 187 | + import openpyxl # lecture streaming du XLSX (le tableau d'équivalence | |
| 188 | + # n'est publié qu'en XLSX par l'INSPQ / Données Québec) | |
| 189 | + chemin = glob.glob(os.path.join(TMP, "inspq", "**", | |
| 190 | + "*TableCorrespondances*Quebec2021*.xlsx"), | |
| 191 | + recursive=True)[0] | |
| 192 | + wb = openpyxl.load_workbook(chemin, read_only=True) | |
| 193 | + ws = wb["Données"] | |
| 194 | + it = ws.iter_rows(values_only=True) | |
| 195 | + entete = [str(c) for c in next(it)] | |
| 196 | + i_ad = entete.index("AD") | |
| 197 | + i_qm = entete.index("QuintMat") # quintile matériel, échelle Québec | |
| 198 | + i_qs = entete.index("QuintSoc") # quintile social, échelle Québec | |
| 199 | + vus = {} | |
| 200 | + for ligne in it: | |
| 201 | + ad = str(ligne[i_ad]).strip() if ligne[i_ad] is not None else "" | |
| 202 | + if len(ad) != 8 or not ad.startswith("24"): | |
| 203 | + continue | |
| 204 | + qm, qs = ligne[i_qm], ligne[i_qs] | |
| 205 | + qm = int(qm) if qm not in (None, "") else None | |
| 206 | + qs = int(qs) if qs not in (None, "") else None | |
| 207 | + # Une AD scindée entre plusieurs CLSC apparaît sur plusieurs lignes ; | |
| 208 | + # les quintiles « échelle Québec » y sont identiques -> première ligne. | |
| 209 | + if ad not in vus: | |
| 210 | + vus[ad] = (qm, qs) | |
| 211 | + wb.close() | |
| 212 | + cx.executemany("INSERT INTO da_defav VALUES (?,?,?)", | |
| 213 | + [(ad, qm, qs) for ad, (qm, qs) in vus.items()]) | |
| 214 | + return len(vus) | |
| 215 | + | |
| 216 | + | |
| 217 | +# --------------------------------------------------------------------------- | |
| 218 | +# 3. ecoles — localisation MEQ + IMSE/SFR | |
| 219 | +# --------------------------------------------------------------------------- | |
| 220 | +def _lire_defav_meq(): | |
| 221 | + """Déciles IMSE/SFR par code d'organisme (public seulement). | |
| 222 | + Priorité au fichier primaire si un code figure dans les deux fichiers.""" | |
| 223 | + deciles = {} | |
| 224 | + for nom in ("defav_sec.csv", "defav_prim.csv"): # prim écrase sec | |
| 225 | + with open(os.path.join(TMP, nom), newline="", encoding="utf-8-sig") as f: | |
| 226 | + for ligne in csv.DictReader(f): | |
| 227 | + code = ligne["Code_Org"].strip() | |
| 228 | + imse = ligne["Rang_Decile_IMSE"].strip() | |
| 229 | + sfr = ligne["Rang_Decile_SFR"].strip() | |
| 230 | + imse = int(imse) if imse else None | |
| 231 | + sfr = int(sfr) if sfr else None | |
| 232 | + if imse is None and sfr is None and code in deciles: | |
| 233 | + continue # ne pas écraser une valeur par du vide | |
| 234 | + deciles[code] = (imse, sfr) | |
| 235 | + return deciles | |
| 236 | + | |
| 237 | + | |
| 238 | +def _coord(ligne, cx_col, cy_col): | |
| 239 | + """Extrait (lat, lng) WGS84 ; COORD_X = longitude, COORD_Y = latitude.""" | |
| 240 | + try: | |
| 241 | + lng = float(ligne[cx_col]) | |
| 242 | + lat = float(ligne[cy_col]) | |
| 243 | + except (ValueError, TypeError, KeyError): | |
| 244 | + return None | |
| 245 | + # garde-fou : bornes du Québec | |
| 246 | + if not (-80.0 <= lng <= -55.0 and 44.0 <= lat <= 63.0): | |
| 247 | + return None | |
| 248 | + return (lat, lng) | |
| 249 | + | |
| 250 | + | |
| 251 | +def construire_ecoles(cx): | |
| 252 | + deciles = _lire_defav_meq() | |
| 253 | + | |
| 254 | + # --- Public : 1 ligne par immeuble -> agrégation par code d'organisme --- | |
| 255 | + publics = {} | |
| 256 | + with open(os.path.join(TMP, "pps_public_ecole.csv"), newline="", | |
| 257 | + encoding="utf-8-sig") as f: | |
| 258 | + for ligne in csv.DictReader(f): | |
| 259 | + code = ligne["CD_ORGNS"].strip() | |
| 260 | + if not code: | |
| 261 | + continue | |
| 262 | + e = publics.setdefault(code, { | |
| 263 | + "nom": ligne["NOM_OFFCL_ORGNS"].strip() or ligne["NOM_COURT_ORGNS"].strip(), | |
| 264 | + "coord": None, "prim": False, "sec": False, | |
| 265 | + }) | |
| 266 | + e["prim"] |= ligne["PRIM"].strip() == "1" | |
| 267 | + e["sec"] |= ligne["SEC"].strip() == "1" | |
| 268 | + if e["coord"] is None: # premier immeuble géocodé du fichier | |
| 269 | + e["coord"] = _coord(ligne, "COORD_X_LL84_IMM", "COORD_Y_LL84_IMM") | |
| 270 | + | |
| 271 | + # --- Privé : installations (flags d'ordre + coords) regroupées par | |
| 272 | + # établissement responsable (CD_ORGNS_RESP) --- | |
| 273 | + noms_prives = {} | |
| 274 | + with open(os.path.join(TMP, "pps_prive_etablissement.csv"), newline="", | |
| 275 | + encoding="utf-8-sig") as f: | |
| 276 | + for ligne in csv.DictReader(f): | |
| 277 | + noms_prives[ligne["CD_ORGNS"].strip()] = ( | |
| 278 | + ligne["NOM_OFFCL"].strip() or ligne["NOM_COURT"].strip()) | |
| 279 | + | |
| 280 | + prives = {} | |
| 281 | + with open(os.path.join(TMP, "pps_prive_installation.csv"), newline="", | |
| 282 | + encoding="utf-8-sig") as f: | |
| 283 | + for ligne in csv.DictReader(f): | |
| 284 | + code = ligne["CD_ORGNS_RESP"].strip() or ligne["CD_ORGNS"].strip() | |
| 285 | + if not code: | |
| 286 | + continue | |
| 287 | + e = prives.setdefault(code, { | |
| 288 | + "nom": noms_prives.get(code) or ligne["NOM_OFFCL"].strip(), | |
| 289 | + "coord": None, "prim": False, "sec": False, | |
| 290 | + }) | |
| 291 | + e["prim"] |= ligne["PRIM"].strip() == "1" | |
| 292 | + e["sec"] |= ligne["SEC"].strip() == "1" | |
| 293 | + if e["coord"] is None: | |
| 294 | + e["coord"] = _coord(ligne, "COORD_X_LL84", "COORD_Y_LL84") | |
| 295 | + | |
| 296 | + lignes = [] | |
| 297 | + for reseau, ecoles in (("public", publics), ("privé", prives)): | |
| 298 | + for code, e in ecoles.items(): | |
| 299 | + # On ne garde que le primaire/secondaire (exclut préscolaire seul, | |
| 300 | + # formation professionnelle et éducation des adultes). | |
| 301 | + if not (e["prim"] or e["sec"]): | |
| 302 | + continue | |
| 303 | + ordre = ("primaire-secondaire" if e["prim"] and e["sec"] | |
| 304 | + else "primaire" if e["prim"] else "secondaire") | |
| 305 | + lat, lng = e["coord"] if e["coord"] else (None, None) | |
| 306 | + imse, sfr = deciles.get(code, (None, None)) if reseau == "public" \ | |
| 307 | + else (None, None) | |
| 308 | + lignes.append((code, e["nom"], lat, lng, ordre, reseau, imse, sfr)) | |
| 309 | + | |
| 310 | + cx.executemany("INSERT OR IGNORE INTO ecoles VALUES (?,?,?,?,?,?,?,?)", | |
| 311 | + lignes) | |
| 312 | + return len(lignes) | |
| 313 | + | |
| 314 | + | |
| 315 | +# --------------------------------------------------------------------------- | |
| 316 | +# 4. meta | |
| 317 | +# --------------------------------------------------------------------------- | |
| 318 | +def construire_meta(cx): | |
| 319 | + meta = { | |
| 320 | + "date_construction": date.today().isoformat(), | |
| 321 | + "script": "scripts/build_contexte.py", | |
| 322 | + "source_pmd": ("Statistique Canada, Base de données sur les mesures de " | |
| 323 | + "proximité (PMD) 2021, no 17-26-0002, édition 2023. " | |
| 324 | + "https://www150.statcan.gc.ca/n1/pub/17-26-0002/172600022023001-eng.htm"), | |
| 325 | + "licence_pmd": ("Licence du gouvernement ouvert – Canada " | |
| 326 | + "(https://ouvert.canada.ca/fr/licence-du-gouvernement-ouvert-canada). " | |
| 327 | + "Contient de l'information visée par la Licence du " | |
| 328 | + "gouvernement ouvert – Canada, Statistique Canada."), | |
| 329 | + "note_pmd": ("Mesures prox_idx_* déjà normalisées 0..1 par StatCan à " | |
| 330 | + "l'échelle nationale (min-max des mesures brutes de " | |
| 331 | + "proximité par îlot de diffusion). Agrégées ici par AD " | |
| 332 | + "(DAUID 24*) en moyenne pondérée par la population de " | |
| 333 | + "l'îlot (DBPOP), repli sur la moyenne simple des valeurs " | |
| 334 | + "non nulles si aucun îlot peuplé. NULL = aucune donnée."), | |
| 335 | + "source_defav": ("INSPQ, Indice de défavorisation matérielle et sociale " | |
| 336 | + "2021, tableau d'équivalence complet (échelle Québec), " | |
| 337 | + "version avril 2024. https://www.donneesquebec.ca/recherche/" | |
| 338 | + "dataset/indice-de-defavorisation-du-quebec-2021"), | |
| 339 | + "licence_defav": "Creative Commons Attribution 4.0 (CC-BY 4.0) — INSPQ.", | |
| 340 | + "note_defav": ("QuintMat/QuintSoc, échelle Québec : 1 = plus favorisé, " | |
| 341 | + "5 = plus défavorisé. NULL = AD non cotée (pop. faible, " | |
| 342 | + "institutionnelle, etc.). Tableau publié en XLSX " | |
| 343 | + "seulement (pas de CSV)."), | |
| 344 | + "source_ecoles": ("MEQ-MES, Localisation des établissements d'enseignement " | |
| 345 | + "du réseau scolaire (CSV pps_public_ecole, " | |
| 346 | + "pps_prive_installation, pps_prive_etablissement). " | |
| 347 | + "https://www.donneesquebec.ca/recherche/dataset/" | |
| 348 | + "localisation-des-etablissements-d-enseignement-du-" | |
| 349 | + "reseau-scolaire-au-quebec"), | |
| 350 | + "source_imse_sfr": ("MEQ, Indices de défavorisation (IMSE et SFR), déciles " | |
| 351 | + "1 (favorisé) à 10 (défavorisé), année scolaire " | |
| 352 | + "2025-2026. https://www.donneesquebec.ca/recherche/" | |
| 353 | + "dataset/indices-de-defavorisation"), | |
| 354 | + "licence_ecoles": "Creative Commons Attribution 4.0 (CC-BY 4.0) — Gouvernement du Québec (MEQ).", | |
| 355 | + "note_ecoles": ("code = code d'ORGANISME MEQ (7 chiffres), pas le code " | |
| 356 | + "de bâtiment : c'est la clé des fichiers IMSE/SFR " | |
| 357 | + "(Code_Org). Coordonnées WGS84 du premier immeuble/" | |
| 358 | + "installation géocodé. ordre ∈ {primaire, secondaire, " | |
| 359 | + "primaire-secondaire} ; préscolaire seul, FP et adultes " | |
| 360 | + "exclus. IMSE/SFR NULL pour le privé (non publié) et " | |
| 361 | + "pour les écoles publiques sous seuil de diffusion."), | |
| 362 | + } | |
| 363 | + cx.executemany("INSERT INTO meta VALUES (?,?)", sorted(meta.items())) | |
| 364 | + return len(meta) | |
| 365 | + | |
| 366 | + | |
| 367 | +# --------------------------------------------------------------------------- | |
| 368 | +# Validations | |
| 369 | +# --------------------------------------------------------------------------- | |
| 370 | +def valider(cx): | |
| 371 | + q = lambda s: cx.execute(s).fetchone()[0] | |
| 372 | + print("\n=== VALIDATIONS ===") | |
| 373 | + n_pmd = q("SELECT COUNT(*) FROM da_pmd") | |
| 374 | + print(f"da_pmd : {n_pmd} AD (attendu ~10-13 k)") | |
| 375 | + assert 10000 <= n_pmd <= 15000, "nb d'AD hors plage attendue" | |
| 376 | + for _, col in PMD_COLS: | |
| 377 | + mn, mx, nn = cx.execute( | |
| 378 | + f"SELECT MIN({col}), MAX({col}), SUM({col} IS NOT NULL) FROM da_pmd" | |
| 379 | + ).fetchone() | |
| 380 | + assert mn is None or (0.0 <= mn and mx <= 1.0), f"{col} hors 0..1" | |
| 381 | + print(f" {col:18s} min={mn:.4f} max={mx:.4f} non-null={nn}") | |
| 382 | + | |
| 383 | + n_def = q("SELECT COUNT(*) FROM da_defav") | |
| 384 | + n_cot = q("SELECT COUNT(*) FROM da_defav WHERE quintile_materiel IS NOT NULL") | |
| 385 | + # Couverture des AD « urbaines » : approximée par les AD de la PMD ayant | |
| 386 | + # une mesure de transport en commun (desserte = milieu urbain). | |
| 387 | + couv = q("""SELECT 100.0 * SUM(d.quintile_materiel IS NOT NULL) / COUNT(*) | |
| 388 | + FROM da_pmd p LEFT JOIN da_defav d ON d.dauid = p.dauid | |
| 389 | + WHERE p.prox_transport IS NOT NULL""") | |
| 390 | + print(f"da_defav : {n_def} AD, {n_cot} cotées ; couverture AD urbaines " | |
| 391 | + f"(proxy transport) = {couv:.1f} % (exigé >= 90 %)") | |
| 392 | + assert couv >= 90.0, "couverture urbaine insuffisante" | |
| 393 | + | |
| 394 | + n_ec = q("SELECT COUNT(*) FROM ecoles") | |
| 395 | + n_geo = q("SELECT COUNT(*) FROM ecoles WHERE lat IS NOT NULL") | |
| 396 | + n_imse = q("SELECT COUNT(*) FROM ecoles WHERE imse IS NOT NULL") | |
| 397 | + print(f"ecoles : {n_ec} (géocodées {n_geo}, avec IMSE {n_imse})") | |
| 398 | + assert n_ec >= 2500, "trop peu d'écoles" | |
| 399 | + | |
| 400 | + print("échantillon Limoilou (attendu IMSE élevé) / Sillery (faible) :") | |
| 401 | + for motif in ("%Saint-Fidèle%", "%Saint-Paul-Apôtre%", "%Stadacona%", | |
| 402 | + "%Saint-Michel de Sillery%", "%Les Sources%", "%Rochebelle%"): | |
| 403 | + for row in cx.execute( | |
| 404 | + "SELECT code, nom, ordre, reseau, imse, sfr FROM ecoles " | |
| 405 | + "WHERE nom LIKE ? LIMIT 3", (motif,)): | |
| 406 | + print(" ", row) | |
| 407 | + | |
| 408 | + | |
| 409 | +# --------------------------------------------------------------------------- | |
| 410 | +def main(): | |
| 411 | + print("Téléchargement / extraction des sources …") | |
| 412 | + telecharger() | |
| 413 | + | |
| 414 | + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) | |
| 415 | + if os.path.exists(DB_PATH): | |
| 416 | + os.remove(DB_PATH) # reconstruction complète | |
| 417 | + cx = sqlite3.connect(DB_PATH) | |
| 418 | + cx.executescript(""" | |
| 419 | + CREATE TABLE da_pmd( | |
| 420 | + dauid TEXT PRIMARY KEY, | |
| 421 | + prox_epicerie REAL, prox_pharmacie REAL, prox_garderie REAL, | |
| 422 | + prox_ecole_prim REAL, prox_ecole_sec REAL, prox_sante REAL, | |
| 423 | + prox_bibliotheque REAL, prox_parc REAL, prox_transport REAL, | |
| 424 | + prox_emploi REAL); | |
| 425 | + CREATE TABLE da_defav( | |
| 426 | + dauid TEXT PRIMARY KEY, | |
| 427 | + quintile_materiel INTEGER, quintile_social INTEGER); | |
| 428 | + CREATE TABLE ecoles( | |
| 429 | + code TEXT PRIMARY KEY, nom TEXT, lat REAL, lng REAL, | |
| 430 | + ordre TEXT, reseau TEXT, imse INTEGER, sfr INTEGER); | |
| 431 | + CREATE TABLE meta(cle TEXT PRIMARY KEY, valeur TEXT); | |
| 432 | + """) | |
| 433 | + | |
| 434 | + print("Construction da_pmd (streaming du CSV national ~500 k lignes) …") | |
| 435 | + print(f" {construire_da_pmd(cx)} AD insérées") | |
| 436 | + print("Construction da_defav …") | |
| 437 | + print(f" {construire_da_defav(cx)} AD insérées") | |
| 438 | + print("Construction ecoles …") | |
| 439 | + print(f" {construire_ecoles(cx)} écoles insérées") | |
| 440 | + construire_meta(cx) | |
| 441 | + cx.commit() | |
| 442 | + | |
| 443 | + valider(cx) | |
| 444 | + cx.close() | |
| 445 | + print(f"\nOK -> {DB_PATH} ({os.path.getsize(DB_PATH)/1e6:.1f} Mo)") | |
| 446 | + | |
| 447 | + if "--clean" in sys.argv: | |
| 448 | + import shutil | |
| 449 | + shutil.rmtree(TMP, ignore_errors=True) | |
| 450 | + print(f"{TMP} supprimé") | |
| 451 | + | |
| 452 | + | |
| 453 | +if __name__ == "__main__": | |
| 454 | + main() | |
added
scripts/build_environnement.py
+432 −0
@@ -0,0 +1,432 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# -*- coding: utf-8 -*- | |
| 3 | +""" | |
| 4 | +Construction de data/staging-environnement.db — enrichissement « environnement » | |
| 5 | +des fiches Lou-Ka : îlots de chaleur (INSPQ), criminalité (SPVM), indice de | |
| 6 | +gravité de la criminalité (Statistique Canada). | |
| 7 | + | |
| 8 | +Tables produites (schéma contractuel — ne pas modifier) : | |
| 9 | + | |
| 10 | + heat(coord_key TEXT PRIMARY KEY, classe INTEGER, ecart REAL) | |
| 11 | + coord_key = "lat,lng" avec exactement 4 décimales, équivalent SQLite : | |
| 12 | + printf('%.4f', ROUND(lat,4)) || ',' || printf('%.4f', ROUND(lng,4)) | |
| 13 | + classe : 1 à 9 (INSPQ ICFU 2020-2022). SENS VÉRIFIÉ dans les métadonnées | |
| 14 | + du jeu Données Québec : classes 1-2-3 = îlots de FRAÎCHEUR urbains | |
| 15 | + (1 = le plus frais), classes 8-9 = îlots de CHALEUR urbains (9 = le plus | |
| 16 | + chaud). NULL si hors des centres de population couverts (nodata). | |
| 17 | + ecart : écart de température relatif en °C par rapport à un boisé voisin | |
| 18 | + (raster écoumène 2021). NULL si hors écoumène (nodata). | |
| 19 | + | |
| 20 | + crime_mtl(id INTEGER PRIMARY KEY, lat REAL, lng REAL, ts REAL, categorie TEXT) | |
| 21 | + Actes criminels SPVM des 24 derniers mois, coordonnées valides seulement. | |
| 22 | + ts = epoch UTC (minuit) de la date de l'acte. | |
| 23 | + | |
| 24 | + igc(service TEXT, annee INTEGER, indice REAL, taux REAL) | |
| 25 | + indice = Indice de gravité de la criminalité (StatCan 35-10-0187 par | |
| 26 | + corps de police ; 35-10-0026 pour le Canada). | |
| 27 | + taux = taux de criminalité par 100 000 hab. (infractions au Code criminel | |
| 28 | + sauf délits de la route, StatCan 35-10-0179 / 35-10-0177 via l'API WDS). | |
| 29 | + 5 dernières années disponibles. | |
| 30 | + | |
| 31 | + meta(cle TEXT PRIMARY KEY, valeur TEXT) : sources, dates, attributions. | |
| 32 | + | |
| 33 | +Usage : | |
| 34 | + .venv/bin/python scripts/build_environnement.py # tout reconstruire | |
| 35 | + .venv/bin/python scripts/build_environnement.py --etapes crime # rafraîchir la | |
| 36 | + criminalité seulement (à relancer mensuellement) | |
| 37 | + .venv/bin/python scripts/build_environnement.py --etapes chaleur \ | |
| 38 | + --coords nouvelles.txt # échantillonner UNIQUEMENT une liste de nouvelles | |
| 39 | + coordonnées (une paire "lat,lng" par ligne) et les upserter dans heat | |
| 40 | + .venv/bin/python scripts/build_environnement.py --garder-cache # ne pas | |
| 41 | + supprimer les GeoTIFF téléchargés (~9,6 Go) après l'exécution | |
| 42 | + | |
| 43 | +Dépendances : rasterio, pyproj (pip install rasterio pyproj). | |
| 44 | +Licences : INSPQ/CERFO CC-BY 4.0 ; Ville de Montréal CC-BY 4.0 ; | |
| 45 | +Statistique Canada — licence ouverte. | |
| 46 | +""" | |
| 47 | + | |
| 48 | +import argparse | |
| 49 | +import calendar | |
| 50 | +import csv | |
| 51 | +import io | |
| 52 | +import json | |
| 53 | +import shutil | |
| 54 | +import sqlite3 | |
| 55 | +import sys | |
| 56 | +import urllib.request | |
| 57 | +import zipfile | |
| 58 | +from datetime import date, datetime, timedelta | |
| 59 | +from pathlib import Path | |
| 60 | + | |
| 61 | +RACINE = Path(__file__).resolve().parent.parent | |
| 62 | +DB_LOUKA = RACINE / "data" / "louka.db" | |
| 63 | +DB_STAGING = RACINE / "data" / "staging-environnement.db" | |
| 64 | +CACHE_DEFAUT = Path("/tmp/louka-env") | |
| 65 | + | |
| 66 | +# --- Sources ----------------------------------------------------------------- | |
| 67 | +URL_TIF_CLASSES = ("https://dq-prd-bucket1.s3.ca-central-1.amazonaws.com/inspq/" | |
| 68 | + "ICFU2022_CentresPopulation2021_buf2000m_9cl.tif") | |
| 69 | +URL_TIF_ECART = ("https://dq-prd-bucket1.s3.ca-central-1.amazonaws.com/inspq/" | |
| 70 | + "EcartTemperatureRelatif2022_Ecoumene2021.tif") | |
| 71 | +URL_CRIME_CSV = ("https://donnees.montreal.ca/dataset/" | |
| 72 | + "5829b5b0-ea6f-476f-be94-bc2b8797769a/resource/" | |
| 73 | + "c6f482bf-bf0f-4960-8b2f-9982c211addd/download/actes-criminels.csv") | |
| 74 | +URL_STATCAN_ZIP = "https://www150.statcan.gc.ca/n1/tbl/csv/{pid}-fra.zip" | |
| 75 | +URL_WDS = ("https://www150.statcan.gc.ca/t1/wds/rest/" | |
| 76 | + "getDataFromCubePidCoordAndLatestNPeriods") | |
| 77 | + | |
| 78 | +# Le portail donnees.montreal.ca refuse les clients sans User-Agent navigateur. | |
| 79 | +UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " | |
| 80 | + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36") | |
| 81 | + | |
| 82 | +MOIS_CRIME = 24 # fenêtre criminalité (24 derniers mois) | |
| 83 | +ANNEES_IGC = 5 # nombre d'années IGC conservées | |
| 84 | + | |
| 85 | +# Corps de police retenus. | |
| 86 | +# - code_geo : identifiant entre crochets dans la colonne GÉO des CSV StatCan | |
| 87 | +# 35-10-0187 (indice) et 35-10-0026 (Canada). | |
| 88 | +# - pid_taux / geo_taux : cube et memberId Géographie pour le taux de | |
| 89 | +# criminalité via l'API WDS (35-10-0179 = services de police au Québec, | |
| 90 | +# 35-10-0177 = Canada/provinces/RMR). Coordonnée = geo.infraction.stat.0*7 | |
| 91 | +# avec infraction 3 = « Total des infractions au Code criminel (sauf les | |
| 92 | +# délits de la route) » et statistique 2 = « Taux pour 100 000 habitants ». | |
| 93 | +SERVICES = [ | |
| 94 | + # (nom_affiché, code_geo, pid_indice, pid_taux, geo_taux) | |
| 95 | + ("SPVQ (Ville de Québec)", "24215", 35100187, 35100179, 107), | |
| 96 | + ("Ville de Lévis", "24219", 35100187, 35100179, 109), | |
| 97 | + ("SPVM (Montréal)", "24175", 35100187, 35100179, 82), | |
| 98 | + ("Laval", "24141", 35100187, 35100179, 59), | |
| 99 | + ("Longueuil (agglomération)", "24231", 35100187, 35100179, 115), | |
| 100 | + ("Québec (province)", "24", 35100187, 35100179, 1), | |
| 101 | + ("Canada", "CAN", 35100026, 35100177, 1), | |
| 102 | +] | |
| 103 | + | |
| 104 | + | |
| 105 | +# --- Utilitaires --------------------------------------------------------------- | |
| 106 | +def telecharger(url: str, dest: Path, description: str) -> None: | |
| 107 | + """Télécharge url vers dest (flux, pas de chargement en mémoire).""" | |
| 108 | + if dest.exists() and dest.stat().st_size > 0: | |
| 109 | + print(f" [cache] {description} déjà présent : {dest}") | |
| 110 | + return | |
| 111 | + print(f" téléchargement {description} …") | |
| 112 | + req = urllib.request.Request(url, headers={"User-Agent": UA}) | |
| 113 | + tmp = dest.with_suffix(dest.suffix + ".part") | |
| 114 | + with urllib.request.urlopen(req, timeout=120) as rep, open(tmp, "wb") as f: | |
| 115 | + shutil.copyfileobj(rep, f, length=1 << 20) | |
| 116 | + tmp.rename(dest) | |
| 117 | + print(f" ok ({dest.stat().st_size / 1e6:.1f} Mo)") | |
| 118 | + | |
| 119 | + | |
| 120 | +def coord_key(lat: float, lng: float) -> str: | |
| 121 | + """Clé "lat,lng" à 4 décimales — identique à | |
| 122 | + printf('%.4f',ROUND(lat,4))||','||printf('%.4f',ROUND(lng,4)) en SQLite.""" | |
| 123 | + return f"{lat:.4f},{lng:.4f}" | |
| 124 | + | |
| 125 | + | |
| 126 | +def ouvrir_staging() -> sqlite3.Connection: | |
| 127 | + DB_STAGING.parent.mkdir(parents=True, exist_ok=True) | |
| 128 | + conn = sqlite3.connect(DB_STAGING) | |
| 129 | + conn.executescript(""" | |
| 130 | + CREATE TABLE IF NOT EXISTS heat( | |
| 131 | + coord_key TEXT PRIMARY KEY, classe INTEGER, ecart REAL); | |
| 132 | + CREATE TABLE IF NOT EXISTS crime_mtl( | |
| 133 | + id INTEGER PRIMARY KEY, lat REAL, lng REAL, ts REAL, categorie TEXT); | |
| 134 | + CREATE INDEX IF NOT EXISTS idx_crime_lat ON crime_mtl(lat); | |
| 135 | + CREATE TABLE IF NOT EXISTS igc( | |
| 136 | + service TEXT, annee INTEGER, indice REAL, taux REAL); | |
| 137 | + CREATE TABLE IF NOT EXISTS meta(cle TEXT PRIMARY KEY, valeur TEXT); | |
| 138 | + """) | |
| 139 | + return conn | |
| 140 | + | |
| 141 | + | |
| 142 | +def poser_meta(conn: sqlite3.Connection, cle: str, valeur: str) -> None: | |
| 143 | + conn.execute("INSERT OR REPLACE INTO meta(cle, valeur) VALUES(?,?)", | |
| 144 | + (cle, valeur)) | |
| 145 | + | |
| 146 | + | |
| 147 | +# --- Étape 1 : îlots de chaleur ------------------------------------------------- | |
| 148 | +def coords_depuis_louka() -> list[tuple[float, float]]: | |
| 149 | + """Coordonnées uniques (arrondies à 4 décimales par SQLite) des immeubles actifs.""" | |
| 150 | + conn = sqlite3.connect(f"file:{DB_LOUKA}?mode=ro", uri=True) | |
| 151 | + lignes = conn.execute( | |
| 152 | + "SELECT DISTINCT ROUND(lat,4), ROUND(lng,4) FROM listings " | |
| 153 | + "WHERE active=1 AND lat IS NOT NULL AND lng IS NOT NULL").fetchall() | |
| 154 | + conn.close() | |
| 155 | + return [(la, lo) for la, lo in lignes] | |
| 156 | + | |
| 157 | + | |
| 158 | +def coords_depuis_fichier(chemin: Path) -> list[tuple[float, float]]: | |
| 159 | + """Fichier texte : une paire « lat,lng » par ligne (les valeurs sont | |
| 160 | + ré-arrondies à 4 décimales).""" | |
| 161 | + paires = [] | |
| 162 | + for ligne in chemin.read_text().splitlines(): | |
| 163 | + ligne = ligne.strip() | |
| 164 | + if not ligne or ligne.startswith("#"): | |
| 165 | + continue | |
| 166 | + la, lo = ligne.split(",") | |
| 167 | + paires.append((round(float(la), 4), round(float(lo), 4))) | |
| 168 | + return paires | |
| 169 | + | |
| 170 | + | |
| 171 | +def echantillonner_raster(chemin_tif: Path, coords: list[tuple[float, float]]): | |
| 172 | + """Échantillonne un GeoTIFF aux points (lat,lng) WGS84, par fenêtres | |
| 173 | + (rasterio.sample), sans jamais charger le raster entier. On reprojette les | |
| 174 | + POINTS vers le CRS du raster (EPSG:32198 Québec Lambert pour les deux | |
| 175 | + rasters INSPQ), jamais le raster. Renvoie une liste de valeurs (None si | |
| 176 | + nodata ou hors emprise).""" | |
| 177 | + import math | |
| 178 | + | |
| 179 | + import rasterio | |
| 180 | + from pyproj import Transformer | |
| 181 | + | |
| 182 | + with rasterio.open(chemin_tif) as ds: | |
| 183 | + transfo = Transformer.from_crs("EPSG:4326", ds.crs, always_xy=True) | |
| 184 | + xy = [transfo.transform(lo, la) for la, lo in coords] | |
| 185 | + valeurs = [] | |
| 186 | + for echantillon in ds.sample(xy, indexes=1, masked=True): | |
| 187 | + v = echantillon[0] | |
| 188 | + if v is None or getattr(v, "mask", False) or math.isnan(float(v)) \ | |
| 189 | + or (ds.nodata is not None and float(v) == float(ds.nodata)): | |
| 190 | + valeurs.append(None) | |
| 191 | + else: | |
| 192 | + valeurs.append(float(v)) | |
| 193 | + return valeurs | |
| 194 | + | |
| 195 | + | |
| 196 | +def etape_chaleur(conn: sqlite3.Connection, cache: Path, | |
| 197 | + coords: list[tuple[float, float]] | None) -> None: | |
| 198 | + print("== Étape chaleur (INSPQ ICFU 2020-2022) ==") | |
| 199 | + if coords is None: | |
| 200 | + coords = coords_depuis_louka() | |
| 201 | + print(f" {len(coords)} coordonnées à échantillonner") | |
| 202 | + if not coords: | |
| 203 | + return | |
| 204 | + | |
| 205 | + tif_classes = cache / "ICFU2022_CentresPopulation2021_buf2000m_9cl.tif" | |
| 206 | + tif_ecart = cache / "EcartTemperatureRelatif2022_Ecoumene2021.tif" | |
| 207 | + telecharger(URL_TIF_CLASSES, tif_classes, "raster classes ICFU (≈34 Mo)") | |
| 208 | + telecharger(URL_TIF_ECART, tif_ecart, "raster écarts de température (≈9,6 Go)") | |
| 209 | + | |
| 210 | + print(" échantillonnage classes (9 niveaux) …") | |
| 211 | + classes = echantillonner_raster(tif_classes, coords) | |
| 212 | + print(" échantillonnage écarts de température …") | |
| 213 | + ecarts = echantillonner_raster(tif_ecart, coords) | |
| 214 | + | |
| 215 | + lignes = [] | |
| 216 | + for (la, lo), cl, ec in zip(coords, classes, ecarts): | |
| 217 | + lignes.append((coord_key(la, lo), | |
| 218 | + int(cl) if cl is not None else None, | |
| 219 | + round(ec, 2) if ec is not None else None)) | |
| 220 | + conn.executemany( | |
| 221 | + "INSERT OR REPLACE INTO heat(coord_key, classe, ecart) VALUES(?,?,?)", | |
| 222 | + lignes) | |
| 223 | + | |
| 224 | + poser_meta(conn, "heat.source", | |
| 225 | + "INSPQ/CERFO — Îlots de chaleur/fraîcheur urbains et écarts de " | |
| 226 | + "température relatifs 2020-2022 (Données Québec)") | |
| 227 | + poser_meta(conn, "heat.url_classes", URL_TIF_CLASSES) | |
| 228 | + poser_meta(conn, "heat.url_ecart", URL_TIF_ECART) | |
| 229 | + poser_meta(conn, "heat.licence", "CC-BY 4.0 — attribution : INSPQ et CERFO") | |
| 230 | + poser_meta(conn, "heat.sens_classes", | |
| 231 | + "Vérifié dans les métadonnées Données Québec : classes 1-2-3 = " | |
| 232 | + "îlots de fraîcheur urbains (1 = le plus frais), classes 8-9 = " | |
| 233 | + "îlots de chaleur urbains (9 = le plus chaud). Classement des " | |
| 234 | + "écarts de température par centre de population (2021), " | |
| 235 | + "buffer 2 km, résolution 15 m, EPSG:32198.") | |
| 236 | + poser_meta(conn, "heat.ecart_unite", | |
| 237 | + "°C, écart relatif à un boisé de proximité (écoumène 2021) ; " | |
| 238 | + "NULL = hors écoumène / hors centres de population (nodata)") | |
| 239 | + poser_meta(conn, "heat.coord_key", | |
| 240 | + "printf('%.4f',ROUND(lat,4))||','||printf('%.4f',ROUND(lng,4))") | |
| 241 | + poser_meta(conn, "heat.telecharge_le", datetime.now().isoformat(timespec="seconds")) | |
| 242 | + conn.commit() | |
| 243 | + total, avec_classe = conn.execute( | |
| 244 | + "SELECT COUNT(*), COUNT(classe) FROM heat").fetchone() | |
| 245 | + print(f" heat : {total} lignes ({avec_classe} avec classe)") | |
| 246 | + | |
| 247 | + | |
| 248 | +# --- Étape 2 : criminalité SPVM -------------------------------------------------- | |
| 249 | +def etape_crime(conn: sqlite3.Connection, cache: Path) -> None: | |
| 250 | + print("== Étape criminalité (actes criminels SPVM) ==") | |
| 251 | + fichier = cache / "actes-criminels.csv" | |
| 252 | + # Toujours rafraîchir : le jeu est mis à jour quotidiennement. | |
| 253 | + if fichier.exists(): | |
| 254 | + fichier.unlink() | |
| 255 | + telecharger(URL_CRIME_CSV, fichier, "CSV actes criminels (≈30 Mo)") | |
| 256 | + | |
| 257 | + seuil = date.today() - timedelta(days=MOIS_CRIME * 365 // 12) | |
| 258 | + lignes = [] | |
| 259 | + ecartees = 0 | |
| 260 | + with open(fichier, encoding="utf-8-sig", newline="") as f: | |
| 261 | + for rang in csv.DictReader(f): | |
| 262 | + try: | |
| 263 | + d = datetime.strptime(rang["DATE"].strip(), "%Y-%m-%d").date() | |
| 264 | + la = float(rang["LATITUDE"]) | |
| 265 | + lo = float(rang["LONGITUDE"]) | |
| 266 | + except (ValueError, KeyError, TypeError): | |
| 267 | + ecartees += 1 | |
| 268 | + continue | |
| 269 | + if d < seuil: | |
| 270 | + continue | |
| 271 | + # bornes de validité : île de Montréal et environs | |
| 272 | + if not (44.5 <= la <= 46.5 and -75.0 <= lo <= -72.5): | |
| 273 | + ecartees += 1 | |
| 274 | + continue | |
| 275 | + ts = float(calendar.timegm(d.timetuple())) # epoch UTC (minuit) | |
| 276 | + lignes.append((la, lo, ts, rang["CATEGORIE"].strip())) | |
| 277 | + | |
| 278 | + conn.execute("DELETE FROM crime_mtl") | |
| 279 | + conn.executemany( | |
| 280 | + "INSERT INTO crime_mtl(lat, lng, ts, categorie) VALUES(?,?,?,?)", lignes) | |
| 281 | + poser_meta(conn, "crime.source", | |
| 282 | + "Ville de Montréal (SPVM) — Actes criminels, " | |
| 283 | + "https://donnees.montreal.ca/dataset/actes-criminels") | |
| 284 | + poser_meta(conn, "crime.licence", "CC-BY 4.0 — attribution : Ville de Montréal") | |
| 285 | + poser_meta(conn, "crime.fenetre", f"{MOIS_CRIME} derniers mois (depuis {seuil})") | |
| 286 | + poser_meta(conn, "crime.telecharge_le", datetime.now().isoformat(timespec="seconds")) | |
| 287 | + conn.commit() | |
| 288 | + n = conn.execute("SELECT COUNT(*) FROM crime_mtl").fetchone()[0] | |
| 289 | + print(f" crime_mtl : {n} actes conservés (fenêtre ≥ {seuil}), " | |
| 290 | + f"{ecartees} lignes écartées (coordonnées/date invalides)") | |
| 291 | + | |
| 292 | + | |
| 293 | +# --- Étape 3 : indice de gravité de la criminalité (StatCan) --------------------- | |
| 294 | +def lire_igc_csv(cache: Path, pid: int) -> dict[tuple[str, int], float]: | |
| 295 | + """Extrait {(code_geo, annee): IGC} du CSV complet d'un tableau StatCan.""" | |
| 296 | + zip_path = cache / f"statcan_{pid}.zip" | |
| 297 | + telecharger(URL_STATCAN_ZIP.format(pid=pid), zip_path, f"tableau StatCan {pid}") | |
| 298 | + valeurs: dict[tuple[str, int], float] = {} | |
| 299 | + with zipfile.ZipFile(zip_path) as z, \ | |
| 300 | + io.TextIOWrapper(z.open(f"{pid}.csv"), encoding="utf-8-sig") as f: | |
| 301 | + for rang in csv.DictReader(f, delimiter=";"): | |
| 302 | + if rang["Statistiques"] != "Indice de gravité de la criminalité": | |
| 303 | + continue | |
| 304 | + if not rang["VALEUR"]: | |
| 305 | + continue | |
| 306 | + geo = rang["GÉO"] | |
| 307 | + code = geo.rsplit("[", 1)[-1].rstrip("]") if "[" in geo else \ | |
| 308 | + ("CAN" if geo == "Canada" else geo) | |
| 309 | + valeurs[(code, int(rang["PÉRIODE DE RÉFÉRENCE"]))] = float(rang["VALEUR"]) | |
| 310 | + return valeurs | |
| 311 | + | |
| 312 | + | |
| 313 | +def taux_via_wds(requetes: list[tuple[int, int]]) -> dict[tuple[int, int], dict[int, float]]: | |
| 314 | + """Taux de criminalité (Code criminel sauf délits de la route, /100 000 hab.) | |
| 315 | + via l'API WDS. requetes = [(pid, geo_member)] ; renvoie | |
| 316 | + {(pid, geo): {annee: taux}}.""" | |
| 317 | + charge = [{"productId": pid, | |
| 318 | + "coordinate": f"{geo}.3.2.0.0.0.0.0.0.0", | |
| 319 | + "latestN": ANNEES_IGC} | |
| 320 | + for pid, geo in requetes] | |
| 321 | + req = urllib.request.Request( | |
| 322 | + URL_WDS, data=json.dumps(charge).encode(), | |
| 323 | + headers={"Content-Type": "application/json", "User-Agent": UA}) | |
| 324 | + with urllib.request.urlopen(req, timeout=120) as rep: | |
| 325 | + reponses = json.load(rep) | |
| 326 | + resultat: dict[tuple[int, int], dict[int, float]] = {} | |
| 327 | + for rep_item in reponses: | |
| 328 | + if rep_item.get("status") != "SUCCESS": | |
| 329 | + continue | |
| 330 | + obj = rep_item["object"] | |
| 331 | + geo = int(obj["coordinate"].split(".")[0]) | |
| 332 | + serie = {} | |
| 333 | + for pt in obj["vectorDataPoint"]: | |
| 334 | + if pt.get("value") is None: | |
| 335 | + continue | |
| 336 | + serie[int(pt["refPer"][:4])] = float(pt["value"]) | |
| 337 | + resultat[(obj["productId"], geo)] = serie | |
| 338 | + return resultat | |
| 339 | + | |
| 340 | + | |
| 341 | +def etape_igc(conn: sqlite3.Connection, cache: Path) -> None: | |
| 342 | + print("== Étape IGC (StatCan 35-10-0187 / 35-10-0026 + taux WDS) ==") | |
| 343 | + igc_187 = lire_igc_csv(cache, 35100187) | |
| 344 | + igc_026 = lire_igc_csv(cache, 35100026) | |
| 345 | + indices = {**igc_026, **igc_187} | |
| 346 | + | |
| 347 | + annees = sorted({a for (_, a) in igc_187})[-ANNEES_IGC:] | |
| 348 | + print(f" années retenues : {annees}") | |
| 349 | + | |
| 350 | + try: | |
| 351 | + taux = taux_via_wds(sorted({(s[3], s[4]) for s in SERVICES})) | |
| 352 | + except Exception as exc: # le taux est optionnel (NULL si indisponible) | |
| 353 | + print(f" avertissement : taux WDS indisponible ({exc}) — taux=NULL") | |
| 354 | + taux = {} | |
| 355 | + | |
| 356 | + conn.execute("DELETE FROM igc") | |
| 357 | + for nom, code_geo, _pid_ind, pid_taux, geo_taux in SERVICES: | |
| 358 | + for annee in annees: | |
| 359 | + indice = indices.get((code_geo, annee)) | |
| 360 | + t = taux.get((pid_taux, geo_taux), {}).get(annee) | |
| 361 | + conn.execute("INSERT INTO igc(service, annee, indice, taux) " | |
| 362 | + "VALUES(?,?,?,?)", (nom, annee, indice, t)) | |
| 363 | + | |
| 364 | + poser_meta(conn, "igc.source", | |
| 365 | + "Statistique Canada, tableaux 35-10-0187 (IGC par service de " | |
| 366 | + "police), 35-10-0026 (IGC Canada/provinces), 35-10-0179 et " | |
| 367 | + "35-10-0177 (taux de criminalité — Code criminel sauf délits " | |
| 368 | + "de la route, par 100 000 hab., via l'API WDS)") | |
| 369 | + poser_meta(conn, "igc.licence", | |
| 370 | + "Licence ouverte de Statistique Canada — adapté de Statistique " | |
| 371 | + "Canada ; cela ne constitue pas une approbation par Statistique " | |
| 372 | + "Canada de ce produit") | |
| 373 | + poser_meta(conn, "igc.telecharge_le", datetime.now().isoformat(timespec="seconds")) | |
| 374 | + conn.commit() | |
| 375 | + n = conn.execute("SELECT COUNT(*) FROM igc").fetchone()[0] | |
| 376 | + print(f" igc : {n} lignes") | |
| 377 | + | |
| 378 | + | |
| 379 | +# --- Point d'entrée --------------------------------------------------------------- | |
| 380 | +def principal() -> int: | |
| 381 | + ap = argparse.ArgumentParser( | |
| 382 | + description="Construit data/staging-environnement.db (chaleur, " | |
| 383 | + "criminalité, IGC).") | |
| 384 | + ap.add_argument("--etapes", default="chaleur,crime,igc", | |
| 385 | + help="étapes à exécuter, séparées par des virgules " | |
| 386 | + "(chaleur, crime, igc) — défaut : toutes") | |
| 387 | + ap.add_argument("--coords", type=Path, default=None, | |
| 388 | + help="fichier de nouvelles coordonnées (« lat,lng » par " | |
| 389 | + "ligne) à échantillonner et upserter dans heat, au " | |
| 390 | + "lieu de relire louka.db") | |
| 391 | + ap.add_argument("--cache", type=Path, default=CACHE_DEFAUT, | |
| 392 | + help=f"répertoire de cache des téléchargements " | |
| 393 | + f"(défaut : {CACHE_DEFAUT})") | |
| 394 | + ap.add_argument("--garder-cache", action="store_true", | |
| 395 | + help="ne pas supprimer les GeoTIFF (~9,6 Go) à la fin") | |
| 396 | + args = ap.parse_args() | |
| 397 | + | |
| 398 | + etapes = {e.strip() for e in args.etapes.split(",")} | |
| 399 | + inconnues = etapes - {"chaleur", "crime", "igc"} | |
| 400 | + if inconnues: | |
| 401 | + ap.error(f"étapes inconnues : {inconnues}") | |
| 402 | + | |
| 403 | + args.cache.mkdir(parents=True, exist_ok=True) | |
| 404 | + conn = ouvrir_staging() | |
| 405 | + poser_meta(conn, "generation.script", "scripts/build_environnement.py") | |
| 406 | + poser_meta(conn, "generation.date", datetime.now().isoformat(timespec="seconds")) | |
| 407 | + poser_meta(conn, "attributions", | |
| 408 | + "Îlots de chaleur : INSPQ et CERFO (CC-BY 4.0) ; Actes " | |
| 409 | + "criminels : Ville de Montréal (CC-BY 4.0) ; IGC et taux de " | |
| 410 | + "criminalité : Statistique Canada (licence ouverte)") | |
| 411 | + | |
| 412 | + coords = coords_depuis_fichier(args.coords) if args.coords else None | |
| 413 | + if "chaleur" in etapes: | |
| 414 | + etape_chaleur(conn, args.cache, coords) | |
| 415 | + if "crime" in etapes: | |
| 416 | + etape_crime(conn, args.cache) | |
| 417 | + if "igc" in etapes: | |
| 418 | + etape_igc(conn, args.cache) | |
| 419 | + conn.commit() | |
| 420 | + conn.close() | |
| 421 | + | |
| 422 | + if not args.garder_cache: | |
| 423 | + for tif in args.cache.glob("*.tif"): | |
| 424 | + print(f" nettoyage : suppression {tif}") | |
| 425 | + tif.unlink() | |
| 426 | + | |
| 427 | + print("Terminé :", DB_STAGING) | |
| 428 | + return 0 | |
| 429 | + | |
| 430 | + | |
| 431 | +if __name__ == "__main__": | |
| 432 | + sys.exit(principal()) | |
added
scripts/build_recensement.py
+461 −0
@@ -0,0 +1,461 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# -*- coding: utf-8 -*- | |
| 3 | +""" | |
| 4 | +build_recensement.py — Construit data/staging-recensement.db pour Lou-Ka. | |
| 5 | + | |
| 6 | +Tables produites (schéma contractuel) : | |
| 7 | + 1. da_poly(dauid, lat_min, lat_max, lng_min, lng_max, poly) | |
| 8 | + - polygones des aires de diffusion (AD) 2021 du Québec, en WGS84, | |
| 9 | + restreints à la zone de couverture des annonces | |
| 10 | + (lat 44.9–47.7 / lng -74.6–-70.2 : Québec, Lévis, Grand Montréal + périphérie). | |
| 11 | + 2. da_stats(dauid, population, densite, age_median, revenu_median, | |
| 12 | + pct_locataires, loyer_moyen, pct_francais, pct_univ) | |
| 13 | + - statistiques du Recensement de 2021 (Profil du recensement). | |
| 14 | + 3. meta(cle, valeur) — provenance et attribution. | |
| 15 | + | |
| 16 | +Sources (Statistique Canada, licence ouverte) : | |
| 17 | + - Polygones : Fichiers des limites cartographiques 2021 (92-160-X / index 92-169-X), | |
| 18 | + shapefile « aires de diffusion », projection Lambert conforme de | |
| 19 | + Statistique Canada (EPSG:3347) : | |
| 20 | + https://www12.statcan.gc.ca/census-recensement/2021/geo/sip-pis/boundary-limites/files-fichiers/lda_000b21a_e.zip | |
| 21 | + (page index : https://www12.statcan.gc.ca/census-recensement/2021/geo/sip-pis/boundary-limites/index2021-fra.cfm) | |
| 22 | + - Statistiques : Profil du recensement 2021, 98-401-X2021006, fichier | |
| 23 | + « Québec » au niveau des aires de diffusion (CSV, ~2 Go décompressé) : | |
| 24 | + https://www12.statcan.gc.ca/census-recensement/2021/dp-pd/prof/details/download-telecharger/comp/GetFile.cfm?Lang=F&FILETYPE=CSV&GEONO=006_Quebec | |
| 25 | + (page : https://www12.statcan.gc.ca/census-recensement/2021/dp-pd/prof/details/download-telecharger.cfm?Lang=F) | |
| 26 | + | |
| 27 | +Usage : | |
| 28 | + .venv/bin/python scripts/build_recensement.py \ | |
| 29 | + --shp /tmp/louka-recensement/lda/lda_000b21a_e.shp \ | |
| 30 | + --csv /tmp/louka-recensement/profil/<fichier>.csv \ | |
| 31 | + --db data/staging-recensement.db \ | |
| 32 | + [--etape poly|stats|valider|tout] | |
| 33 | + | |
| 34 | +Le CSV du profil est STREAMÉ (jamais chargé en mémoire). Les valeurs | |
| 35 | +supprimées par StatCan (confidentialité : symboles x, F, ..., etc.) | |
| 36 | +restent NULL — rien n'est inventé. | |
| 37 | +""" | |
| 38 | + | |
| 39 | +import argparse | |
| 40 | +import csv | |
| 41 | +import datetime | |
| 42 | +import json | |
| 43 | +import os | |
| 44 | +import sqlite3 | |
| 45 | +import sys | |
| 46 | + | |
| 47 | +import pyproj | |
| 48 | +import shapefile # pyshp | |
| 49 | + | |
| 50 | +# --------------------------------------------------------------------------- | |
| 51 | +# Zone de couverture (bbox des annonces Lou-Ka, WGS84) | |
| 52 | +# --------------------------------------------------------------------------- | |
| 53 | +LAT_MIN, LAT_MAX = 44.9, 47.7 | |
| 54 | +LNG_MIN, LNG_MAX = -74.6, -70.2 | |
| 55 | + | |
| 56 | +# Projection source du shapefile StatCan : Lambert conforme conique | |
| 57 | +# « NAD83_Statistics_Canada_Lambert » = EPSG:3347. Cible : WGS84 (EPSG:4326). | |
| 58 | +TRANSFORMER = pyproj.Transformer.from_crs("EPSG:3347", "EPSG:4326", always_xy=True) | |
| 59 | + | |
| 60 | +# --------------------------------------------------------------------------- | |
| 61 | +# Variables retenues dans le Profil du recensement (98-401-X2021006, français). | |
| 62 | +# Chaque entrée : nom exact de la caractéristique (colonne NOM_CARACTÉRISTIQUE, | |
| 63 | +# sans l'indentation) -> clé interne. Les ID (ID_CARACTÉRISTIQUE) sont | |
| 64 | +# découverts dynamiquement en scannant la liste des caractéristiques de la | |
| 65 | +# première géographie du fichier, ce qui rend le script robuste aux | |
| 66 | +# renumérotations éventuelles. | |
| 67 | +# --------------------------------------------------------------------------- | |
| 68 | +CARACTERISTIQUES = { | |
| 69 | + # population et densité (ID attendus : 1 et 6) | |
| 70 | + "Population, 2021": "population", | |
| 71 | + "Densité de la population au kilomètre carré": "densite", | |
| 72 | + # âge médian de la population (ID 40) | |
| 73 | + "Âge médian de la population": "age_median", | |
| 74 | + # revenu total médian des ménages en 2020 (ID 243) | |
| 75 | + "Revenu total médian des ménages en 2020 ($)": "revenu_median", | |
| 76 | + # mode d'occupation : total des ménages (ID 1414) ; « Locataire » | |
| 77 | + # (ID 1416) est résolu par position (voir ENFANTS) | |
| 78 | + "Total - Ménages privés selon le mode d'occupation - Données-échantillon (25 %)": "menages_total", | |
| 79 | + # loyer mensuel moyen des logements loués = frais de logement mensuels | |
| 80 | + # moyens des ménages locataires (ID 1495) | |
| 81 | + "Frais de logement mensuels moyens pour les logements occupés par un ménage locataire ($)": "loyer_moyen", | |
| 82 | + # langue parlée le plus souvent à la maison : total (ID 735) ; le | |
| 83 | + # « Français » (ID 739) est résolu par position (voir ENFANTS) | |
| 84 | + "Total - Langue parlée le plus souvent à la maison pour la population totale à l'exclusion des résidents d'un établissement institutionnel - Données intégrales (100 %)": "langue_total", | |
| 85 | + # scolarité 25-64 ans : total (ID 2014) ; « Baccalauréat ou grade | |
| 86 | + # supérieur » (ID 2024) est résolu par position (voir ENFANTS) | |
| 87 | + "Total - Plus haut certificat, diplôme ou grade pour la population âgée de 25 à 64 ans dans les ménages privés - Données-échantillon (25 %)": "scol_total", | |
| 88 | +} | |
| 89 | + | |
| 90 | +# Sous-caractéristiques ambiguës (leur nom apparaît plusieurs fois dans le | |
| 91 | +# profil, p. ex. « Français » dans chaque section linguistique) : on prend la | |
| 92 | +# PREMIÈRE occurrence qui SUIT la ligne « Total » de leur section. | |
| 93 | +# clé du total parent -> (nom normalisé de l'enfant, clé interne de l'enfant) | |
| 94 | +ENFANTS = { | |
| 95 | + "menages_total": ("Locataire", "locataires"), # ID 1416 | |
| 96 | + "langue_total": ("Français", "francais"), # ID 739 | |
| 97 | + "scol_total": ("Baccalauréat ou grade supérieur", "pct_univ_taux"), # ID 2024 | |
| 98 | +} | |
| 99 | + | |
| 100 | + | |
| 101 | +def _normaliser_nom(nom): | |
| 102 | + """Nettoie un NOM_CARACTÉRISTIQUE : enlève l'indentation et uniformise | |
| 103 | + les apostrophes (le fichier mélange 0x27 et 0x92/'’').""" | |
| 104 | + return nom.strip().replace("\x92", "'").replace("’", "'") | |
| 105 | + | |
| 106 | + | |
| 107 | +# clés normalisées une fois pour toutes | |
| 108 | +CARACTERISTIQUES = {_normaliser_nom(k): v for k, v in CARACTERISTIQUES.items()} | |
| 109 | + | |
| 110 | + | |
| 111 | +def log(msg): | |
| 112 | + print(msg, flush=True) | |
| 113 | + | |
| 114 | + | |
| 115 | +# =========================================================================== | |
| 116 | +# ÉTAPE 1 — POLYGONES (da_poly) | |
| 117 | +# =========================================================================== | |
| 118 | +def construire_poly(chemin_shp, conn): | |
| 119 | + """Lit le shapefile des AD, filtre Québec (PRUID 24) + bbox de couverture, | |
| 120 | + reprojette EPSG:3347 -> WGS84 et écrit da_poly.""" | |
| 121 | + conn.execute("DROP TABLE IF EXISTS da_poly") | |
| 122 | + conn.execute( | |
| 123 | + """CREATE TABLE da_poly( | |
| 124 | + dauid TEXT PRIMARY KEY, | |
| 125 | + lat_min REAL, lat_max REAL, | |
| 126 | + lng_min REAL, lng_max REAL, | |
| 127 | + poly TEXT)""" | |
| 128 | + ) | |
| 129 | + | |
| 130 | + lecteur = shapefile.Reader(chemin_shp) | |
| 131 | + champs = [f[0] for f in lecteur.fields[1:]] | |
| 132 | + i_dauid = champs.index("DAUID") | |
| 133 | + i_pruid = champs.index("PRUID") | |
| 134 | + | |
| 135 | + n_qc = n_gardees = 0 | |
| 136 | + lot = [] | |
| 137 | + for sr in lecteur.iterShapeRecords(): | |
| 138 | + rec = sr.record | |
| 139 | + if rec[i_pruid] != "24": | |
| 140 | + continue | |
| 141 | + n_qc += 1 | |
| 142 | + shp = sr.shape | |
| 143 | + | |
| 144 | + # Pré-filtre grossier : on reprojette les 4 coins de la bbox projetée ; | |
| 145 | + # si même la bbox élargie ne touche pas la zone, on saute la géométrie | |
| 146 | + # complète (économise ~40 % du temps de reprojection). | |
| 147 | + x0, y0, x1, y1 = shp.bbox | |
| 148 | + cx, cy = TRANSFORMER.transform([x0, x1, x0, x1], [y0, y0, y1, y1]) | |
| 149 | + marge = 0.05 # la bbox projetée n'est pas alignée sur les méridiens | |
| 150 | + if (max(cy) + marge < LAT_MIN or min(cy) - marge > LAT_MAX | |
| 151 | + or max(cx) + marge < LNG_MIN or min(cx) - marge > LNG_MAX): | |
| 152 | + continue | |
| 153 | + | |
| 154 | + # Reprojection complète de la géométrie (tous les anneaux). | |
| 155 | + pts = shp.points | |
| 156 | + xs = [p[0] for p in pts] | |
| 157 | + ys = [p[1] for p in pts] | |
| 158 | + lngs, lats = TRANSFORMER.transform(xs, ys) | |
| 159 | + | |
| 160 | + lat_min, lat_max = min(lats), max(lats) | |
| 161 | + lng_min, lng_max = min(lngs), max(lngs) | |
| 162 | + # Filtre exact : intersection de la bbox WGS84 avec la zone visée. | |
| 163 | + if (lat_max < LAT_MIN or lat_min > LAT_MAX | |
| 164 | + or lng_max < LNG_MIN or lng_min > LNG_MAX): | |
| 165 | + continue | |
| 166 | + | |
| 167 | + # Reconstitution des anneaux (parts) : le 1er anneau d'un polygone | |
| 168 | + # shapefile est l'anneau extérieur ; les trous / autres polygones | |
| 169 | + # suivent. Coordonnées [lng, lat], arrondies à 6 décimales (~10 cm). | |
| 170 | + bornes = list(shp.parts) + [len(pts)] | |
| 171 | + anneaux = [] | |
| 172 | + for a in range(len(shp.parts)): | |
| 173 | + d, f = bornes[a], bornes[a + 1] | |
| 174 | + anneaux.append( | |
| 175 | + [[round(lngs[i], 6), round(lats[i], 6)] for i in range(d, f)] | |
| 176 | + ) | |
| 177 | + | |
| 178 | + lot.append(( | |
| 179 | + rec[i_dauid], | |
| 180 | + round(lat_min, 6), round(lat_max, 6), | |
| 181 | + round(lng_min, 6), round(lng_max, 6), | |
| 182 | + json.dumps(anneaux, separators=(",", ":")), | |
| 183 | + )) | |
| 184 | + n_gardees += 1 | |
| 185 | + if len(lot) >= 500: | |
| 186 | + conn.executemany("INSERT INTO da_poly VALUES (?,?,?,?,?,?)", lot) | |
| 187 | + lot = [] | |
| 188 | + | |
| 189 | + if lot: | |
| 190 | + conn.executemany("INSERT INTO da_poly VALUES (?,?,?,?,?,?)", lot) | |
| 191 | + conn.commit() | |
| 192 | + log(f"da_poly : {n_gardees} AD retenues sur {n_qc} AD au Québec") | |
| 193 | + return n_gardees | |
| 194 | + | |
| 195 | + | |
| 196 | +# =========================================================================== | |
| 197 | +# ÉTAPE 2 — STATISTIQUES (da_stats) | |
| 198 | +# =========================================================================== | |
| 199 | +def _detecter_colonnes(entete): | |
| 200 | + """Repère les colonnes utiles du CSV (le fichier FR utilise des noms | |
| 201 | + français ; on tolère les variantes EN par prudence).""" | |
| 202 | + def trouver(*candidats): | |
| 203 | + for c in candidats: | |
| 204 | + for i, nom in enumerate(entete): | |
| 205 | + if nom.strip().lstrip("").upper() == c.upper(): | |
| 206 | + return i | |
| 207 | + raise KeyError(f"colonne introuvable : {candidats} dans {entete}") | |
| 208 | + | |
| 209 | + return { | |
| 210 | + "geo_level": trouver("NIVEAU_GÉO", "GEO_LEVEL"), | |
| 211 | + "alt_geo": trouver("CODE_GÉO_ALT", "ALT_GEO_CODE"), | |
| 212 | + "car_id": trouver("ID_CARACTÉRISTIQUE", "CHARACTERISTIC_ID"), | |
| 213 | + "car_nom": trouver("NOM_CARACTÉRISTIQUE", "CHARACTERISTIC_NAME"), | |
| 214 | + "total": trouver("C1_CHIFFRE_TOTAL", "C1_COUNT_TOTAL"), | |
| 215 | + } | |
| 216 | + | |
| 217 | + | |
| 218 | +def decouvrir_ids(chemin_csv): | |
| 219 | + """1re passe (rapide) : lit les caractéristiques de la première géographie | |
| 220 | + du fichier pour associer chaque variable à son ID_CARACTÉRISTIQUE.""" | |
| 221 | + ids = {} | |
| 222 | + en_attente = [] # [(nom enfant recherché, clé interne)] | |
| 223 | + with open(chemin_csv, newline="", encoding="latin-1") as f: | |
| 224 | + lecteur = csv.reader(f) | |
| 225 | + entete = next(lecteur) | |
| 226 | + col = _detecter_colonnes(entete) | |
| 227 | + premier_geo = None | |
| 228 | + for ligne in lecteur: | |
| 229 | + geo = ligne[col["alt_geo"]] | |
| 230 | + if premier_geo is None: | |
| 231 | + premier_geo = geo | |
| 232 | + elif geo != premier_geo: | |
| 233 | + break # une géographie = la liste complète des caractéristiques | |
| 234 | + cid = int(ligne[col["car_id"]]) | |
| 235 | + nom = _normaliser_nom(ligne[col["car_nom"]]) | |
| 236 | + # sous-caractéristique attendue après son « Total » parent ? | |
| 237 | + for i, (nom_enfant, cle_enfant) in enumerate(en_attente): | |
| 238 | + if nom == nom_enfant and cle_enfant not in ids: | |
| 239 | + ids[cle_enfant] = cid | |
| 240 | + en_attente.pop(i) | |
| 241 | + break | |
| 242 | + cle = CARACTERISTIQUES.get(nom) | |
| 243 | + if cle and cle not in ids: | |
| 244 | + ids[cle] = cid | |
| 245 | + if cle in ENFANTS: | |
| 246 | + en_attente.append(ENFANTS[cle]) | |
| 247 | + return ids, col | |
| 248 | + | |
| 249 | + | |
| 250 | +def _nombre(txt): | |
| 251 | + """Convertit une cellule du profil en float, ou None si valeur supprimée | |
| 252 | + (x, F, .., ..., vide) — on n'invente rien.""" | |
| 253 | + txt = (txt or "").strip().replace(",", ".") | |
| 254 | + if not txt or txt in {"x", "F", "..", "...", "r", "t"}: | |
| 255 | + return None | |
| 256 | + try: | |
| 257 | + return float(txt) | |
| 258 | + except ValueError: | |
| 259 | + return None | |
| 260 | + | |
| 261 | + | |
| 262 | +def construire_stats(chemin_csv, conn): | |
| 263 | + """2e passe : streame tout le CSV, ne garde que les lignes des AD retenues | |
| 264 | + dans da_poly et les 12 caractéristiques utiles, puis calcule les 8 | |
| 265 | + variables finales.""" | |
| 266 | + dauids = {r[0] for r in conn.execute("SELECT dauid FROM da_poly")} | |
| 267 | + if not dauids: | |
| 268 | + raise SystemExit("da_poly est vide — lancer l'étape poly d'abord") | |
| 269 | + | |
| 270 | + ids, col = decouvrir_ids(chemin_csv) | |
| 271 | + attendues = set(CARACTERISTIQUES.values()) | {c for _, c in ENFANTS.values()} | |
| 272 | + manquants = attendues - set(ids) | |
| 273 | + if manquants: | |
| 274 | + raise SystemExit(f"IDs de caractéristiques introuvables : {manquants}") | |
| 275 | + log(f"IDs des caractéristiques : {ids}") | |
| 276 | + ids_voulus = {v: k for k, v in ids.items()} # cid -> clé interne | |
| 277 | + | |
| 278 | + # accumulation : dauid -> {clé interne: valeur brute} | |
| 279 | + donnees = {} | |
| 280 | + niveaux_ad = {"Aire de diffusion", "Dissemination area"} | |
| 281 | + n_lignes = 0 | |
| 282 | + with open(chemin_csv, newline="", encoding="latin-1") as f: | |
| 283 | + lecteur = csv.reader(f) | |
| 284 | + next(lecteur) # entête | |
| 285 | + i_niv, i_geo = col["geo_level"], col["alt_geo"] | |
| 286 | + i_cid, i_tot = col["car_id"], col["total"] | |
| 287 | + for ligne in lecteur: | |
| 288 | + n_lignes += 1 | |
| 289 | + if ligne[i_niv] not in niveaux_ad: | |
| 290 | + continue | |
| 291 | + geo = ligne[i_geo] | |
| 292 | + if geo not in dauids: | |
| 293 | + continue | |
| 294 | + cle = ids_voulus.get(int(ligne[i_cid])) | |
| 295 | + if cle is None: | |
| 296 | + continue | |
| 297 | + donnees.setdefault(geo, {})[cle] = _nombre(ligne[i_tot]) | |
| 298 | + log(f"CSV : {n_lignes} lignes lues, {len(donnees)} AD avec données") | |
| 299 | + | |
| 300 | + conn.execute("DROP TABLE IF EXISTS da_stats") | |
| 301 | + conn.execute( | |
| 302 | + """CREATE TABLE da_stats( | |
| 303 | + dauid TEXT PRIMARY KEY, | |
| 304 | + population INTEGER, | |
| 305 | + densite REAL, | |
| 306 | + age_median REAL, | |
| 307 | + revenu_median REAL, | |
| 308 | + pct_locataires REAL, | |
| 309 | + loyer_moyen REAL, | |
| 310 | + pct_francais REAL, | |
| 311 | + pct_univ REAL)""" | |
| 312 | + ) | |
| 313 | + | |
| 314 | + def pct(part, total): | |
| 315 | + """Pourcentage part/total, NULL si l'un des deux est supprimé/nul.""" | |
| 316 | + if part is None or not total: | |
| 317 | + return None | |
| 318 | + return round(100.0 * part / total, 1) | |
| 319 | + | |
| 320 | + lot = [] | |
| 321 | + for dauid, d in donnees.items(): | |
| 322 | + lot.append(( | |
| 323 | + dauid, | |
| 324 | + int(d["population"]) if d.get("population") is not None else None, | |
| 325 | + d.get("densite"), | |
| 326 | + d.get("age_median"), | |
| 327 | + d.get("revenu_median"), | |
| 328 | + pct(d.get("locataires"), d.get("menages_total")), | |
| 329 | + d.get("loyer_moyen"), | |
| 330 | + pct(d.get("francais"), d.get("langue_total")), | |
| 331 | + pct(d.get("pct_univ_taux"), d.get("scol_total")), | |
| 332 | + )) | |
| 333 | + conn.executemany("INSERT INTO da_stats VALUES (?,?,?,?,?,?,?,?,?)", lot) | |
| 334 | + conn.commit() | |
| 335 | + log(f"da_stats : {len(lot)} AD insérées") | |
| 336 | + return len(lot) | |
| 337 | + | |
| 338 | + | |
| 339 | +# =========================================================================== | |
| 340 | +# ÉTAPE 3 — MÉTADONNÉES (meta) | |
| 341 | +# =========================================================================== | |
| 342 | +def ecrire_meta(conn): | |
| 343 | + conn.execute("DROP TABLE IF EXISTS meta") | |
| 344 | + conn.execute("CREATE TABLE meta(cle TEXT PRIMARY KEY, valeur TEXT)") | |
| 345 | + meta = { | |
| 346 | + "source_polygones": ( | |
| 347 | + "Statistique Canada, Fichiers des limites cartographiques du " | |
| 348 | + "Recensement de 2021 (92-160-X), aires de diffusion, " | |
| 349 | + "lda_000b21a_e.zip — https://www12.statcan.gc.ca/census-recensement/" | |
| 350 | + "2021/geo/sip-pis/boundary-limites/files-fichiers/lda_000b21a_e.zip" | |
| 351 | + ), | |
| 352 | + "source_stats": ( | |
| 353 | + "Statistique Canada, Profil du recensement, Recensement de la " | |
| 354 | + "population de 2021, no 98-401-X2021006 au catalogue, fichier " | |
| 355 | + "Québec au niveau des aires de diffusion — " | |
| 356 | + "https://www12.statcan.gc.ca/census-recensement/2021/dp-pd/prof/" | |
| 357 | + "details/download-telecharger.cfm?Lang=F" | |
| 358 | + ), | |
| 359 | + "date_construction": datetime.date.today().isoformat(), | |
| 360 | + "attribution": ( | |
| 361 | + "Statistique Canada, Recensement de la population de 2021 " | |
| 362 | + "(reproduit et diffusé « tel quel » avec la permission de " | |
| 363 | + "Statistique Canada — Licence ouverte de Statistique Canada)" | |
| 364 | + ), | |
| 365 | + "projection_source": "EPSG:3347 (NAD83 Statistics Canada Lambert) -> EPSG:4326 (WGS84)", | |
| 366 | + "zone_couverture": f"lat {LAT_MIN}–{LAT_MAX}, lng {LNG_MIN}–{LNG_MAX} (Québec/Lévis/Grand Montréal + périphérie)", | |
| 367 | + } | |
| 368 | + conn.executemany("INSERT INTO meta VALUES (?,?)", meta.items()) | |
| 369 | + conn.commit() | |
| 370 | + | |
| 371 | + | |
| 372 | +# =========================================================================== | |
| 373 | +# VALIDATIONS | |
| 374 | +# =========================================================================== | |
| 375 | +def point_dans_poly(lat, lng, anneaux): | |
| 376 | + """Ray casting pair/impair sur l'ensemble des anneaux (gère les trous).""" | |
| 377 | + dedans = False | |
| 378 | + for anneau in anneaux: | |
| 379 | + n = len(anneau) | |
| 380 | + j = n - 1 | |
| 381 | + for i in range(n): | |
| 382 | + xi, yi = anneau[i] | |
| 383 | + xj, yj = anneau[j] | |
| 384 | + if (yi > lat) != (yj > lat) and \ | |
| 385 | + lng < (xj - xi) * (lat - yi) / (yj - yi) + xi: | |
| 386 | + dedans = not dedans | |
| 387 | + j = i | |
| 388 | + return dedans | |
| 389 | + | |
| 390 | + | |
| 391 | +def trouver_dauid(conn, lat, lng): | |
| 392 | + """Retourne le DAUID contenant le point (candidats par bbox, puis PIP).""" | |
| 393 | + for dauid, poly in conn.execute( | |
| 394 | + "SELECT dauid, poly FROM da_poly " | |
| 395 | + "WHERE ? BETWEEN lat_min AND lat_max AND ? BETWEEN lng_min AND lng_max", | |
| 396 | + (lat, lng), | |
| 397 | + ): | |
| 398 | + if point_dans_poly(lat, lng, json.loads(poly)): | |
| 399 | + return dauid | |
| 400 | + return None | |
| 401 | + | |
| 402 | + | |
| 403 | +def valider(conn): | |
| 404 | + n_poly = conn.execute("SELECT COUNT(*) FROM da_poly").fetchone()[0] | |
| 405 | + n_stats = conn.execute( | |
| 406 | + "SELECT COUNT(*) FROM da_stats WHERE dauid IN (SELECT dauid FROM da_poly)" | |
| 407 | + ).fetchone()[0] | |
| 408 | + log(f"Validation : {n_poly} AD dans da_poly ; couverture da_stats = " | |
| 409 | + f"{n_stats}/{n_poly} ({100.0 * n_stats / max(n_poly, 1):.1f} %)") | |
| 410 | + | |
| 411 | + # point de contrôle : colline Parlementaire / Vieux-Québec | |
| 412 | + dauid = trouver_dauid(conn, 46.8139, -71.2329) | |
| 413 | + ok = dauid == "24231035" | |
| 414 | + log(f"Point (46.8139, -71.2329) -> DAUID {dauid} " | |
| 415 | + f"({'OK' if ok else 'ÉCHEC, attendu 24231035'})") | |
| 416 | + | |
| 417 | + # échantillon lisible | |
| 418 | + for nom, lat, lng in [ | |
| 419 | + ("Saint-Roch (Québec)", 46.8163, -71.2258), | |
| 420 | + ("Sillery (Québec)", 46.7702, -71.2601), | |
| 421 | + ("Plateau Mont-Royal (Mtl)", 45.5230, -73.5817), | |
| 422 | + ]: | |
| 423 | + d = trouver_dauid(conn, lat, lng) | |
| 424 | + row = conn.execute( | |
| 425 | + "SELECT population, revenu_median, pct_locataires, loyer_moyen, " | |
| 426 | + "pct_francais, pct_univ FROM da_stats WHERE dauid=?", (d,) | |
| 427 | + ).fetchone() if d else None | |
| 428 | + log(f" {nom}: DAUID={d} stats={row}") | |
| 429 | + return ok and n_poly > 0 and n_stats >= 0.95 * n_poly | |
| 430 | + | |
| 431 | + | |
| 432 | +# =========================================================================== | |
| 433 | +def main(): | |
| 434 | + ap = argparse.ArgumentParser(description=__doc__) | |
| 435 | + ap.add_argument("--shp", help="chemin du shapefile lda_000b21a_e.shp") | |
| 436 | + ap.add_argument("--csv", help="chemin du CSV du profil (98-401-X2021006, Québec)") | |
| 437 | + ap.add_argument("--db", default="data/staging-recensement.db") | |
| 438 | + ap.add_argument("--etape", default="tout", | |
| 439 | + choices=["poly", "stats", "valider", "tout"]) | |
| 440 | + args = ap.parse_args() | |
| 441 | + | |
| 442 | + conn = sqlite3.connect(args.db) | |
| 443 | + try: | |
| 444 | + if args.etape in ("poly", "tout"): | |
| 445 | + if not args.shp: | |
| 446 | + ap.error("--shp requis pour l'étape poly") | |
| 447 | + construire_poly(args.shp, conn) | |
| 448 | + if args.etape in ("stats", "tout"): | |
| 449 | + if not args.csv: | |
| 450 | + ap.error("--csv requis pour l'étape stats") | |
| 451 | + construire_stats(args.csv, conn) | |
| 452 | + ecrire_meta(conn) | |
| 453 | + if args.etape in ("valider", "tout"): | |
| 454 | + ok = valider(conn) | |
| 455 | + sys.exit(0 if ok else 1) | |
| 456 | + finally: | |
| 457 | + conn.close() | |
| 458 | + | |
| 459 | + | |
| 460 | +if __name__ == "__main__": | |
| 461 | + main() | |
modified
scripts/merge_quartier.py
+25 −0
@@ -57,6 +57,31 @@ def main() -> None: | ||
| 57 | 57 | out.commit() |
| 58 | 58 | out.execute("DETACH DATABASE src") |
| 59 | 59 | |
| 60 | + # scores PMD -> rangs centiles parmi les AD du Québec (l'échelle brute est | |
| 61 | + # normalisée nationalement et écrase les valeurs urbaines : un Saint-Roch | |
| 62 | + # très marchable afficherait 17/100 ; son centile québécois le rétablit) | |
| 63 | + import bisect | |
| 64 | + cols = [r[1] for r in out.execute("PRAGMA table_info(da_pmd)") if r[1] != "dauid"] | |
| 65 | + out.execute("DROP TABLE IF EXISTS da_pmd_pct") | |
| 66 | + out.execute("CREATE TABLE da_pmd_pct (dauid TEXT PRIMARY KEY, " | |
| 67 | + + ", ".join(f"{c} INTEGER" for c in cols) + ")") | |
| 68 | + lignes = out.execute(f"SELECT dauid, {', '.join(cols)} FROM da_pmd").fetchall() | |
| 69 | + tries = {c: sorted(v for (v,) in | |
| 70 | + out.execute(f"SELECT {c} FROM da_pmd WHERE {c} IS NOT NULL")) | |
| 71 | + for c in cols} | |
| 72 | + q = (f"INSERT INTO da_pmd_pct VALUES (?{',?' * len(cols)})") | |
| 73 | + for ligne in lignes: | |
| 74 | + vals = [ligne[0]] | |
| 75 | + for i, c in enumerate(cols): | |
| 76 | + v = ligne[i + 1] | |
| 77 | + t = tries[c] | |
| 78 | + if v is None or len(t) < 2: | |
| 79 | + vals.append(None) | |
| 80 | + else: # rang centile parmi les valeurs non nulles du Québec | |
| 81 | + vals.append(round(100 * bisect.bisect_left(t, v) / (len(t) - 1))) | |
| 82 | + out.execute(q, vals) | |
| 83 | + print(f"✓ da_pmd_pct: {len(lignes)} lignes (rangs centiles QC, non-nuls)") | |
| 84 | + | |
| 60 | 85 | # index utiles au runtime |
| 61 | 86 | for idx in [ |
| 62 | 87 | "CREATE INDEX IF NOT EXISTS idx_poly_bbox ON da_poly(lat_min, lat_max)", |
| 63 | 88 | |