SPB Git

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

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>
simon-pierre boucher committed (Aug 9, 2026) parent 1170cea Browse files

9 changed files +1,657 −0

modified .gitignore +2 −0
@@ -7,3 +7,5 @@ frontend/node_modules/
7 frontend/dist/ 7 frontend/dist/
8 .DS_Store 8 .DS_Store
9 reponse.txt 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 .crumbs a:hover { color: var(--green); border-color: var(--green); } 264 .crumbs a:hover { color: var(--green); border-color: var(--green); }
265 .detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; } 265 .detail-grid { display: grid; grid-template-columns: 1.6fr 1fr; gap: 30px; align-items: start; }
266 @media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } } 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 .gallery { display: flex; flex-direction: column; gap: 10px; } 271 .gallery { display: flex; flex-direction: column; gap: 10px; }
269 .gallery-main { 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 out.commit() 57 out.commit()
58 out.execute("DETACH DATABASE src") 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 # index utiles au runtime 85 # index utiles au runtime
61 for idx in [ 86 for idx in [
62 "CREATE INDEX IF NOT EXISTS idx_poly_bbox ON da_poly(lat_min, lat_max)", 87 "CREATE INDEX IF NOT EXISTS idx_poly_bbox ON da_poly(lat_min, lat_max)",
63 88
Menu

Theme