SPB Git

spb/lou-ka Public

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

HTML 99.7%

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 2 days ago (Aug 9, 2026) parent 1170cea

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